From 681c48f1c26e9ab0420e41d1bb97c1aa924a7ee3 Mon Sep 17 00:00:00 2001 From: Abe Jellinek <1770299+AbeJellinek@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:34:40 -0600 Subject: [PATCH] Local API: Don't validate Zotero-Server-ID for file uploads https://github.com/urschrei/pyzotero/issues/344#issuecomment-5108140508 --- .../zotero/xpcom/server/server_localAPI.js | 12 +++- test/tests/server_localAPITest.js | 69 +++++++++++++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/chrome/content/zotero/xpcom/server/server_localAPI.js b/chrome/content/zotero/xpcom/server/server_localAPI.js index 43580e7579..1d700f604c 100644 --- a/chrome/content/zotero/xpcom/server/server_localAPI.js +++ b/chrome/content/zotero/xpcom/server/server_localAPI.js @@ -241,6 +241,10 @@ const exportFormats = new Map([ * Base class for all local API endpoints. Implements pre- and post-processing steps. */ class LocalAPIEndpoint { + // Whether write-method requests to this endpoint must provide a Zotero-Server-ID + // header. Endpoints that validate a different secret can set this to false. + requireServerIDOnWrite = true; + async init(requestData) { if (!Zotero.Prefs.get('httpServer.localAPI.enabled')) { return this.makeResponse(403, 'text/plain', 'Local API is not enabled'); @@ -611,7 +615,7 @@ class LocalAPIEndpoint { * the request is rejected with 412 (Precondition Failed). * - Write requests (POST, PUT, PATCH, DELETE) must provide the header, or * they're rejected with 428 (Precondition Required). Read requests may - * omit it. + * omit it, as may endpoints that set requireServerIDOnWrite to false. * * Returns null when the request may proceed, or an HTTP response array when * it should be rejected. @@ -621,7 +625,8 @@ class LocalAPIEndpoint { _validateServerID(requestData) { let provided = requestData.headers.get('Zotero-Server-ID'); if (!provided) { - if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(requestData.method)) { + if (this.requireServerIDOnWrite + && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(requestData.method)) { return this.makeResponse(428, 'text/plain', 'Zotero-Server-ID not provided'); } return null; @@ -1279,7 +1284,8 @@ Zotero.Server.Endpoints["/api/groups/:groupID/items/:itemKey/file/view/url"] = Z Zotero.Server.LocalAPI.UploadReceiver = class extends LocalAPIEndpoint { supportedMethods = ['POST']; - // Required so the post-write block doesn't insist on a library write check + // Overridden so the write block doesn't run: no API key or library write check + // applies here, and the response isn't a data object async _initInternal(requestData) { try { if (!Zotero.Prefs.get('httpServer.localAPI.enabled')) { diff --git a/test/tests/server_localAPITest.js b/test/tests/server_localAPITest.js index 55604bcfe1..08e9df56b9 100644 --- a/test/tests/server_localAPITest.js +++ b/test/tests/server_localAPITest.js @@ -2198,6 +2198,75 @@ describe("Local API Server", function () { assert.equal(status, 404); }); + it("shouldn't require a server ID on the upload request", async function () { + // Web API clients post the bytes to S3, not to Zotero, so they strip + // Zotero-specific headers from this request -- it has to work without one + let content = "Upload sent without a Zotero-Server-ID header"; + let bytes = new TextEncoder().encode(content); + let tmpPath = PathUtils.join(Zotero.getTempDirectory().path, 'localapi-no-server-id.bin'); + await IOUtils.write(tmpPath, bytes); + let md5 = await Zotero.Utilities.Internal.md5Async(tmpPath); + await IOUtils.remove(tmpPath); + + let { response: authResp } = await apiPost( + `/users/0/items/${attachment.key}/file`, + { + body: `md5=${md5}&filename=no-server-id.bin` + + `&filesize=${bytes.length}&mtime=${Date.now()}`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'If-None-Match': '*', + } + } + ); + + let uploadXhr = await Zotero.HTTP.request('POST', authResp.url, { + headers: { + 'Zotero-Allowed-Request': '1', + 'Content-Type': 'application/octet-stream', + }, + body: content, + successCodes: [201], + responseType: 'text' + }); + assert.equal(uploadXhr.status, 201); + + // And the upload is usable, so the flow completes as normal + let { status } = await apiPost( + `/users/0/items/${attachment.key}/file`, + { + body: `upload=${authResp.uploadKey}`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'If-None-Match': '*', + }, + successCodes: [204] + } + ); + assert.equal(status, 204); + assert.equal( + await Zotero.Utilities.Internal.md5Async(await attachment.getFilePathAsync()), + md5 + ); + }); + + it("should reject an upload with a mismatched server ID with 412", async function () { + let { status } = await Zotero.HTTP.request( + 'POST', apiRoot + '/local/uploads/badkey999', + { + headers: { + 'Zotero-Allowed-Request': '1', + 'Content-Type': 'application/octet-stream', + 'Zotero-Server-ID': 'wrongServerID', + }, + body: 'whatever', + successCodes: [412], + responseType: 'text' + } + ); + assert.equal(status, 412); + }); + it("should reject PATCH partial upload with 405", async function () { let { status } = await apiPatch( `/users/0/items/${attachment.key}/file?algorithm=xdelta&upload=foo`,