Back to Blog
high SEVERITY6 min read

How Sensitive Data Exposure happens in Zotero plugins and how to fix it

A high-severity data exposure vulnerability in `Zotero.ts` automatically transmitted complete document metadata—including private notes, attachment paths, and tags—to external LLM services without user consent. The fix replaces broad `item.toJSON()` serialization with explicit field selection, sending only essential bibliographic data.

O
By Orbis AppSec
Published September 7, 2026Reviewed September 7, 2026

Answer Summary

Sensitive Data Exposure (CWE-200) in a Zotero plugin's `selectedItems2documents()` function leaked private metadata to OpenAI and other external LLMs via `JSON.stringify(item.toJSON())`. The vulnerability in `src/modules/Meet/Zotero.ts:47` sent full item data including tags, notes, relations, and attachment paths. The fix explicitly constructs a minimal object with only `title`, `abstractNote`, and `date` fields before transmission, eliminating exposure of sensitive metadata while preserving core functionality for LLM-powered similarity search.

Vulnerability at a Glance

cweCWE-200
fixExplicit field whitelisting: `{title, abstractNote, date}` instead of full object serialization
riskAutomatic transmission of private Zotero metadata to external LLM APIs without consent
languageTypeScript
root cause`JSON.stringify(item.toJSON())` serialized complete item data including sensitive fields
vulnerabilitySensitive Data Exposure

Introduction

In a Zotero plugin designed to enhance research workflows with LLM-powered features, we discovered a high-severity sensitive data exposure in src/modules/Meet/Zotero.ts. The selectedItems2documents() function at line 47 was automatically extracting complete document metadata—including private notes, attachment paths, tags, and relations—and transmitting this data to external LLM APIs like OpenAI without explicit user confirmation per operation.

The vulnerable code used JSON.stringify(item.toJSON()) to serialize entire Zotero item objects. While convenient for development, this pattern captured far more than the bibliographic data needed for similarity search, creating a significant privacy risk for researchers who might have sensitive annotations, unpublished notes, or confidential file locations stored in their Zotero libraries.

The Vulnerability Explained

The Problematic Code Pattern

The vulnerability resided in the selectedItems2documents() function, specifically at line 47:

async function selectedItems2documents(key: string) {
  const docs = ZoteroPane.getSelectedItems().map((item: Zotero.Item) => {
    const text = JSON.stringify(item.toJSON());  // ← VULNERABLE: Line 47
    return new Document({
      pageContent: text.slice(0, 500),
      metadata: {
        // ...
      }
    });
  });
  // ... routing to external LLM via getRelatedText
}

The item.toJSON() method returns a complete serialization of a Zotero item, which includes:

Field Category Examples Risk Level
Core bibliographic title, abstract, date Low — intended for sharing
Personal annotations tags, notes, relations High — private research data
System metadata attachment paths, library IDs High — reveals file system structure
Sync metadata modification dates, sync states Medium — operational intelligence

This serialized data then flowed through getRelatedText to external LLM services when the plugin wasn't in local LLM mode, with no per-operation confirmation dialog.

How It Could Be Exploited

Consider a realistic scenario: A researcher uses this plugin to find related papers for their work-in-progress manuscript. They select items from their Zotero library, unaware that the plugin is sending not just titles and abstracts, but also:

  • Private reading notes they added to items: "notes": [{"note": "Discuss with Bob about potential collaboration with CompetitorCorp..."}]
  • File paths revealing organizational structure: "path": "file:///Users/researcher/AcmeCorp/SecretProject/..."
  • Tag relationships exposing research interests: "tags": [{"tag": "whistleblower-interview"}, {"tag": "unpublished-2024"}]

All of this data would be transmitted to OpenAI's API (or other configured LLM providers), stored in their logs, potentially used for model training, and subject to their data retention policies—without any explicit consent for this specific transmission.

Real-World Impact

For academic researchers, journalists, legal professionals, and corporate R&D teams using Zotero, this represents a critical privacy breach:

  • Unintentional disclosure of confidential research directions
  • Exposure of source identities through notes and tags
  • Revealing unpublished work through modification patterns
  • Compliance violations for GDPR, HIPAA, or institutional data handling policies

The Fix

The remediation replaces the dangerous broad serialization with explicit field selection, constructing only the minimal object needed for LLM similarity search:

async function selectedItems2documents(key: string) {
  const docs = ZoteroPane.getSelectedItems().map((item: Zotero.Item) => {
    // Only send the minimal, non-sensitive bibliographic fields needed for
    // similarity search to external LLM services, instead of the full
    // item.toJSON() payload (which can include tags, notes, relations,
    // attachment paths and other private metadata).
    const text = JSON.stringify({
      title: item.getField("title"),
      abstractNote: item.getField("abstractNote"),
      date: item.getField("date")
    });
    return new Document({
      pageContent: text.slice(0, 500),
      metadata: {
        // ...
      }
    });
  });
}

Before vs. After Comparison

Aspect Before (Vulnerable) After (Fixed)
Data fields All toJSON() output Explicit: title, abstractNote, date
Serialization JSON.stringify(item.toJSON()) JSON.stringify({title, abstractNote, date})
Sensitivity risk High — complete metadata exposure Low — only public bibliographic data
User control Implicit via mode setting Same, but with minimal data exposure

Why This Fix Works

The fix implements data minimization by design:

  1. Explicit field access via item.getField() ensures only intended fields are retrieved
  2. Object literal construction creates a new, clean object with no inherited properties
  3. No sensitive field access — the fix deliberately excludes tags, notes, relations, attachments, and other sensitive collections
  4. Preserved functionality — the three selected fields (title, abstractNote, date) provide sufficient context for LLM-powered similarity search

Prevention & Best Practices

For Plugin Developers

  1. Audit toJSON() and similar methods: Any broad serialization method likely exposes more than intended. Always review what fields these methods return.

  2. Implement field allowlists: When transmitting data externally, explicitly construct objects with only necessary fields:
    ```typescript
    // ❌ Dangerous
    const payload = JSON.stringify(complexObject);

// ✅ Safe
const payload = JSON.stringify({
field1: complexObject.getSafeField1(),
field2: complexObject.getSafeField2()
});
```

  1. Add per-operation consent: For LLM integrations, require explicit user confirmation before each external API call, with clear disclosure of what data will be sent.

  2. Support local-only modes: Ensure local LLM functionality is robust and clearly distinguishable from cloud-based processing.

Detection & Standards

  • CWE-200: Exposure of Sensitive Information to an Unauthorized Actor
  • OWASP Top 10 2021: A01:2021 – Broken Access Control (data exposure variant)
  • NIST Privacy Framework: Data Minimization and Purpose Specification

Tools for Detection

Static analysis rules can identify this pattern:
- Calls to toJSON() or similar serialization methods where the result flows to external network requests
- JSON.stringify() with complex object arguments that originate from sensitive data sources
- Missing field filtering before external API transmission

Key Takeaways

  • Never use item.toJSON() for external transmission in Zotero plugins—this method returns complete item data including private metadata
  • The selectedItems2documents() function now constructs explicit field objects with only title, abstractNote, and date before any LLM API calls
  • Data minimization must be implemented at the source, not just secured in transit—encryption doesn't fix over-collection
  • LLM integrations require privacy-by-design architecture with clear boundaries between local and cloud processing modes
  • Per-operation consent dialogs should disclose exactly which fields will be transmitted, not just generic "send to AI" prompts

How Orbis AppSec Detected This

Source: Zotero item data via ZoteroPane.getSelectedItems() in src/modules/Meet/Zotero.ts

Sink: JSON.stringify(item.toJSON()) at line 47, with resulting string flowing to external LLM APIs through getRelatedText

Missing control: No field-level filtering or data minimization before serialization; absence of user confirmation per transmission operation

CWE: CWE-200: Exposure of Sensitive Information to an Unauthorized Actor

Fix: Replaced JSON.stringify(item.toJSON()) with explicit construction of a minimal object containing only title, abstractNote, and date fields

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Conclusion

This vulnerability illustrates a critical lesson for the LLM integration era: convenience methods like toJSON() are dangerous for external data transmission. The fix demonstrates that security and functionality can coexist—by explicitly selecting only necessary fields, the plugin maintains its core LLM-powered features while eliminating privacy risks.

For developers building research tools, academic plugins, or any software bridging local data with cloud AI services, this case underscores the importance of data minimization, explicit consent, and careful review of serialization patterns. Privacy cannot be an afterthought in AI-powered workflows.

References

Frequently Asked Questions

What is Sensitive Data Exposure?

Sensitive Data Exposure occurs when private information—such as personal notes, file paths, or metadata—is inadvertently transmitted or stored in an insecure manner, often through overly broad data serialization or inadequate access controls.

How do you prevent Sensitive Data Exposure in TypeScript?

Use explicit field selection instead of broad serialization methods like `toJSON()` or spreading objects. Implement allowlists of safe fields, validate data before external transmission, and require explicit user confirmation for operations involving sensitive data.

What CWE is Sensitive Data Exposure?

CWE-200: Exposure of Sensitive Information to an Unauthorized Actor

Is data encryption enough to prevent Sensitive Data Exposure?

No—encryption protects data in transit and at rest, but doesn't address whether the data should be transmitted at all. This vulnerability required minimizing what data leaves the system, not just securing its transmission.

Can static analysis detect Sensitive Data Exposure?

Yes, static analysis can identify patterns like calls to broad serialization methods (`toJSON()`, `JSON.stringify` with complex objects) that flow to external network requests, especially when tainted by sensitive data sources.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #161

Related Articles

high

How missing dependency update cooldowns happen in GitHub Dependabot configurations and how to fix it

A semgrep scan flagged `.github/dependabot.yml` for lacking a cooldown period, meaning Dependabot would immediately propose updates to brand-new package versions across npm, Bundler, and Docker ecosystems. The fix adds a `cooldown: default-days: 7` block to every `package-ecosystem` entry, forcing a one-week waiting period before newly published releases are considered — reducing exposure to malicious or unstable package drops.

high

How dependabot-missing-cooldown happens in GitHub Actions/Node.js and how to fix it

The repository's `.github/dependabot.yml` had no cooldown period configured, meaning Dependabot could immediately propose updates to newly published package versions with zero time for the community to flag malware or instability. The fix adds a `cooldown` block with `default-days: 7` to both the `npm` and `github-actions` ecosystems, forcing a 7-day waiting period before new releases are surfaced as update PRs.

high

How Path Traversal Happens in TensorFlow's Data Service and How to Fix It

TensorFlow's data service dispatcher validated dataset IDs against forward-slash traversal attacks but overlooked backslash characters on non-Windows platforms, allowing attackers to escape the root directory. A targeted fix adds explicit backslash validation across all platforms, closing a high-severity path traversal vulnerability in the snapshot management system.

critical

How Unbounded WebSocket Message Handling Causes Resource Exhaustion in Node.js and How to Fix It

The WebSocketCrossServerAdapter class in a popular Node.js WebSocket library lacked any rate limiting on inbound messages, allowing attackers to flood Redis nodes and WebSocket servers with high-volume traffic. The fix introduces a configurable `rateLimit` option that caps messages per connection per second, preventing resource exhaustion while preserving legitimate functionality.

critical

How Remote Code Execution Happens in Handlebars Template Compilation and How to Fix It

CVE-2026-33937 is a critical remote code execution vulnerability in Handlebars.js that allows attackers to execute arbitrary code by passing maliciously crafted Abstract Syntax Tree (AST) objects to the compile() function. The vulnerability was patched in version 4.7.9, and we've upgraded to protect against this threat vector.

high

How SQL-injection-style template literal injection happens in JavaScript DOM rendering and how to fix it

A Semgrep rule (`utils.custom.sql-injection-template-literal`) flagged `src/export/SheetMusicView.js` for building a query/markup string out of a JavaScript template literal with untrusted values interpolated directly into it. In this case the sink was an `<option value="${s.id}">${s.name}</option>` string used to build the snippet picker, meaning any snippet name containing `"` or `<` could break out of the attribute and inject arbitrary HTML. The fix introduces an `_escapeHtml()` helper and ro