Local API: Don't validate Zotero-Server-ID for file uploads
Some checks are pending
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run

https://github.com/urschrei/pyzotero/issues/344#issuecomment-5108140508
This commit is contained in:
Abe Jellinek 2026-07-28 12:34:40 -06:00
parent caeffa9f41
commit 681c48f1c2
2 changed files with 78 additions and 3 deletions

View file

@ -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')) {

View file

@ -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`,