Skip to content

Add a gated external API for tab classification and saving - #637

Open
mlsimon734 wants to merge 1 commit into
zotero:masterfrom
mlsimon734:external-tab-api
Open

Add a gated external API for tab classification and saving#637
mlsimon734 wants to merge 1 commit into
zotero:masterfrom
mlsimon734:external-tab-api

Conversation

@mlsimon734

Copy link
Copy Markdown

Background

Posted to zotero-dev on 2026-08-17 and had no replies, so this is the same proposal as
a reviewable diff: https://groups.google.com/g/zotero-dev/c/Gd-U-z0rh10

The request is older than that thread. In #60, @cmcaine (Tridactyl) asked whether the
Connector would expose a function to other add-ons, and sketched the same design this
patch implements:

Such an API would be thru onmessageexternal, probably passing a tab id [...] If there
were any security concerns we could work through those and possible add a whitelist of
other addons that can use the API.

That issue was closed on the unrelated keyboard-shortcut question, so the API question was
never answered either way. The allowlist is the one thing that has changed since the
zotero-dev post
, which described a proof of concept that accepted calls from any
extension. It no longer does.

Problem

Tabglutton is a companion browser extension that manages tab backlogs and can route scholarly tabs to Zotero. Without a Connector-facing classification API, a companion extension has to approximate Zotero's translator coverage with a hostname list for arXiv, OpenReview, PubMed, bioRxiv, publishers, journals, proxies, PDFs, and other sources. That duplicates logic the Connector has already run for each tab and will drift from Zotero's actual site coverage.

Change

With the new preference at its default, nothing in this patch runs. The listener is registered but refuses every message, so an unmodified install behaves exactly as it does today.

This adds two versioned requests over the standard runtime.onMessageExternal path:

{ "action": "getTabInfo", "version": 1, "tabId": 123 }
{ "action": "saveTab", "version": 1, "tabId": 123 }

For an authorized caller, getTabInfo returns the Connector's current result for that tab:

{
  "ok": true,
  "state": "ready",
  "isPDF": false,
  "translator": {
    "itemType": "preprint",
    "label": "arXiv.org"
  }
}

state is detecting while translator detection has not completed and no PDF has been identified. translator is omitted when the Connector has no translator result. This is the load-bearing half of the API: a generic saveTab operation can invoke the toolbar workflow, but it cannot tell the caller which tabs are papers. Returning the already-computed top translator type and PDF state avoids reproducing Zotero's translator coverage outside Zotero.

saveTab invokes the existing browser action for the requested tab and waits for the page-saving promise. Its successful response is:

{ "ok": true, "status": "saved" }

The existing Chromium action-permission check and script-injection guard remain in the path. A cancelled permission or injection check returns cancelled, and surfaced save failures return error instead of reporting saved. The request also retains the Connector's first-use interaction check. Unsupported versions, unknown actions, and invalid tab IDs receive explicit error statuses.

Why not /connector/detect

Raised as an open question in the zotero-dev post, and worth answering here.

The Connector has already run translator detection in the page for every tab the user has
open, against the live DOM. getTabInfo returns that existing per-tab result. Asking the
client to detect instead would mean recomputing from content the calling extension would
have to gather and ship, which is a different and weaker answer for JS-rendered pages —
and it would not cover the PDF case the Connector already tracks per tab.

That is the reasoning, not a measurement. If /connector/detect is the route maintainers
would rather see taken, that changes the shape of this patch substantially and is worth
knowing before any of it is reviewed in detail.

Authorization

The listener is disabled by default. A caller must have a sender.id, and that extension ID must appear in the new externalAPI.allowedExtensions preference. The preference defaults to an empty array. Web pages have no sender.id and are refused outright.

This is the whitelist suggested in #60. It uses the same static-array preference shape as allowedInterceptHosts and allowedCSLExtensionHosts. Users can edit it in the Connector's existing Config Editor, so this change does not add preferences UI. A static allowlist is a deliberate minimal authorization model, not a claim that it is the ideal long-term interaction: a first-use approval prompt would avoid asking users to find and type an extension ID.

Verification

The following results were measured in the original proof of concept:

  • The full companion-extension test suite passed, and both of its browser targets built. The patched Connector built build/firefox/ and build/manifestv3/, with the external listener present in both generated backgrounds.
  • In Chrome 151, both unpacked MV3 extensions were loaded in a throwaway profile. getTabInfo classified https://arxiv.org/abs/1706.03762 as { "itemType": "preprint", "label": "arXiv.org" }. The same setup exercised the full selected-tab route: the Connector saved the paper to Zotero, the companion extension reported one Zotero save with no failures, and the source tab closed.
  • At Connector commit c279ccc61d80f99b8d9275e9315d05cb66617f2e, both Connector targets built with the API in their generated backgrounds. In a live Firefox 134.0.2 run with both temporary add-ons, getTabInfo returned unauthorized until externalAPI.allowedExtensions contained tabglutton@addons.local, then returned { "state": "ready", "isPDF": false, "translator": { "itemType": "preprint", "label": "arXiv.org" } } for the arXiv abstract. A bad version returned unsupported-version, and a bad action returned unknown-action.
  • In that Firefox session, changing the allowlist through the Config Editor reached the running background without a restart. Prefs.set is already proxied from pages to the background, and the same session that wrote the preference was then authorized by it.
  • saveTab was exercised in Firefox 134.0.2 against a live Zotero client. It returned { "ok": true, "status": "saved" } in 1.7 seconds; one second later the library contained a preprint titled "Attention Is All You Need" with a note and a PDF attachment. The run was repeated successfully in a profile where the Connector had never been clicked.
  • Firefox source and generated output were validated. Fully automated Firefox installation remained unavailable in the test environment because its Firefox 134 automation runtime predated WebDriver BiDi's webExtension.install command, so the end-to-end Firefox browser smoke setup remained manual.

The following are deliberate limits rather than measured guarantees:

  • saved means the Connector's page-saving operation resolved without a surfaced failure. It is not independent verification that the resulting library item exists.
  • The static allowlist is intentionally the smallest defensible authorization model for this proposal.
  • Development builds may use a Connector ID override in the companion extension. A production integration would use the published Connector ID.
  • This API patch remains separate from the companion extension. No Zotero AGPL source is bundled into the MIT-licensed extension.

Tests

No automated tests are included. The Connector's suite has no existing coverage for
onMessageExternal, and the behaviour that matters here — authorization, translator
state, and a save that resolves — was exercised in live browsers instead, as recorded
above. Happy to add tests in whatever shape maintainers prefer.

Current upstream

The patch was originally written against 48ad1fe0 and was previously ported to c279ccc61d80f99b8d9275e9315d05cb66617f2e, where the browser action had moved to Zotero.HostPermissions.checkChromiumActionPermissions and gained the _ensureScriptsInjected guard. This version has now been regenerated and verified against master 97a8b413bcf9e03e5c586d945076f7418f885f04 (2026-08-27).

The c279ccc patch applied cleanly with no behavioral changes needed. Since that port, the touched files gained an unrelated PageSaving.onSaveAsWebpage debug message and the unrelated singleFileConfig preference; neither changed this API's integration points.

At 97a8b413, npm install and ./build.sh -d completed successfully. The generated build/firefox/background.js and build/manifestv3/background.js both contain _handleExternalMessage and the runtime.onMessageExternal listener.

Exposes two versioned requests over runtime.onMessageExternal so a companion
extension can ask what the Connector already knows about a tab, rather than
approximating Zotero's translator coverage with its own hostname list:

  { "action": "getTabInfo", "version": 1, "tabId": 123 }
  { "action": "saveTab",    "version": 1, "tabId": 123 }

getTabInfo returns the tab's current translator result and PDF state; saveTab
invokes the existing browser action and waits on the page-saving promise,
keeping the Chromium action-permission check and the script-injection guard in
the path.

Both are gated on a new externalAPI.allowedExtensions preference, an empty
array by default, following the shape of allowedInterceptHosts and
allowedCSLExtensionHosts so it is editable in the existing Config Editor with
no new preferences UI. A caller with no sender.id — every web page — is refused
outright. With the preference at its default the listener answers nothing, so
an unmodified install is unchanged.
@adomasven

Copy link
Copy Markdown
Member

Given the age of AI, we are quite a bit more concerned about various ways that malicious actors can use something like this to exfiltrate user data, or in other ways gain unauthorized access, and as such I'm more cautious about merging funcitonality like this, than when I was in 2018, when originally making a comment that we would accept a PR.

2 particular things come to mind given this specific changeset:

  1. The UI for authorizing allowed external extensions is the Advanced Config editor. This is not good because it may be easy for users to get instructed to add Tridactyl extension id, but later, if Tridactyl for some reason misbehaves, it will appear as though Zotero Connector is misbehaving for them, and users will have a hard time discovering the reason. There's also the issue of hostile takeover/sale of extensions, which if they at some point offered Zotero Connector integration, could expose our users to unwanted behaviour, once again with difficulty in determining the source of this behaviour.
  2. As it's currently implemented, it may cause various Zotero prompts, that would normally only be triggered by user action (i.e. button click), to be displayed "automatically". This includes prompts for Zotero being offline, first-time use, permissions, and others. This will very likely cause our users to come to our support forums, about this, if it's triggered automatically on page load, etc.

So if we wanted to accept something like this, at the very least, we should make the UI for configuring it explicit, in Zotero Connector Preferences. Moreover, every time a Zotero action is initiated by an external extension, the progress window, and also any prompt would have to clearly indicate, that the action was initiated by the external extension identified by its name, with a quick way to disable it.

Even then, given the very limited number of users this would serve, and the relative complexity of additions needed, I'm not sure if this is something we would like to add. @abaevbog @AbeJellinek do you have any thoughts?

@AbeJellinek

Copy link
Copy Markdown
Member

Is there precedent in other OSS extensions for a cross-extension control API? I can't really think of any other extensions I've used that would want to expose something like this, but I'm sure it's been done. We'd want to emulate an existing secure approach.

I'm not overly concerned about allowing silent access to the current detection status, but I think we should show a prompt before each extension-triggered translation that says the name of the extension and allows the user to allow, reject, or block the extension. If we did that, given how limited the API we're exposing here is, I wouldn't worry too much about potential extension hijacking, and I don't think we absolutely need a configuration UI. If an extension became malicious, the worst it could do would be to trigger saves, which the user could reject/block.

@abaevbog

abaevbog commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

I think I'm mainly in agreement with @adomasven here. I'm somewhat wary of the possibility for item save to get triggered from elsewhere without an immediately clear way for the user (and us for troubleshooting) to see it. I'm also not aware of any examples of similar cross-extensions API.

Besides, I believe we somewhat assume that the page is in the state the user is happy with when translation happens. When the snapshot is being saved, what you see if what you get. It gets tricky if that is no longer the case - like what happens if, say, the selected tab is unloaded?

Relying on Zotero local API for detection of the page feels like a somewhat safer path to me despite extra work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

4 participants