From f359bfb579be48d42f82c099954ac0ab354467e2 Mon Sep 17 00:00:00 2001 From: flfeders Date: Wed, 30 Sep 2026 19:57:54 +0200 Subject: [PATCH] fix(scanner): accept Epson empty write probes --- backend/src/modules/scanner-inbox.service.ts | 43 +++++++++++++++----- backend/src/routes/scannerInboxGateway.ts | 4 ++ backend/tests/scannerInbox.test.ts | 42 ++++++++++++++++++- docs/scanner-dokumentenablage.md | 4 +- 4 files changed, 80 insertions(+), 13 deletions(-) diff --git a/backend/src/modules/scanner-inbox.service.ts b/backend/src/modules/scanner-inbox.service.ts index 9daf85d1..ba9e784e 100644 --- a/backend/src/modules/scanner-inbox.service.ts +++ b/backend/src/modules/scanner-inbox.service.ts @@ -27,10 +27,14 @@ const objectStorage: ScannerStorage = { }, } -export function validateScannerFile(filename: string, body: Buffer) { +function validateScannerFilename(filename: string) { if (!filename || filename.length > 200 || /[\\/\u0000-\u001f\u007f]/.test(filename) || filename.startsWith(".")) { throw scannerError(400, "Bitte einen einzelnen Dateinamen ohne Unterordner verwenden.") } +} + +export function validateScannerFile(filename: string, body: Buffer) { + validateScannerFilename(filename) if (!body.length) throw scannerError(400, "Die Datei ist leer.") if (body.length > SCANNER_UPLOAD_LIMIT) throw scannerError(413, "Die Datei überschreitet 100 MB.") const extension = filename.split(".").pop()?.toLowerCase() @@ -135,21 +139,38 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc .where(and(eq(scannerInboxDocuments.fileId, fileId), eq(scannerInboxDocuments.tenantId, tenantId))) return { id: fileId, folder: folderId, type: typeId } }) + const assertWritable = async (tx: Pick, identity: ScannerIdentity) => { + // Hold these locks until the write or write probe has finished. + const [tenant] = await tx.select({ locked: tenants.locked }).from(tenants) + .where(eq(tenants.id, identity.tenantId)).for("share") + if (!tenant) throw scannerError(401, "Scanner-Zugang ungültig.") + if (tenant.locked === "maintenance_tenant") throw scannerError(423, "Der Mandant wird gerade gewartet.") + const [active] = await tx.select({ tenantId: scannerInboxConnections.tenantId }).from(scannerInboxConnections) + .where(and(eq(scannerInboxConnections.tenantId, identity.tenantId), eq(scannerInboxConnections.username, identity.username), + eq(scannerInboxConnections.passwordHash, identity.passwordHash), eq(scannerInboxConnections.active, true))).for("share") + if (!active) throw scannerError(401, "Scanner-Zugang ungültig.") + } + const probe = async (identity: ScannerIdentity, filename: string, onlyIfNew = false) => { + validateScannerFilename(filename) + if (!/\.(pdf|jpe?g|png|tiff?)$/i.test(filename)) { + throw scannerError(415, "Bitte eine vollständige PDF-, JPEG-, PNG- oder TIFF-Datei übertragen.") + } + await db.transaction(async tx => { + await assertWritable(tx, identity) + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${`${identity.tenantId}:${filename}`}, 0))`) + const [existing] = await tx.select({ fileId: scannerInboxDocuments.fileId }).from(scannerInboxDocuments) + .where(and(eq(scannerInboxDocuments.tenantId, identity.tenantId), eq(scannerInboxDocuments.remoteFilename, filename))).limit(1) + if (existing) throw scannerError(onlyIfNew ? 412 : 409, "Der Dateiname ist bereits vorhanden.") + // Epson probes writability with an empty PUT. No file, receipt or reservation is stored. + }) + } const accept = async (identity: ScannerIdentity, filename: string, body: Buffer, onlyIfNew = false) => { const contentType = validateScannerFile(filename, body) const checksum = digest(body) let uploadedKey: string | undefined try { return await db.transaction(async tx => { - // Coordinate with tenant maintenance and credential revocation during the upload. - const [tenant] = await tx.select({ locked: tenants.locked }).from(tenants) - .where(eq(tenants.id, identity.tenantId)).for("share") - if (!tenant) throw scannerError(401, "Scanner-Zugang ungültig.") - if (tenant.locked === "maintenance_tenant") throw scannerError(423, "Der Mandant wird gerade gewartet.") - const [active] = await tx.select({ tenantId: scannerInboxConnections.tenantId }).from(scannerInboxConnections) - .where(and(eq(scannerInboxConnections.tenantId, identity.tenantId), eq(scannerInboxConnections.username, identity.username), - eq(scannerInboxConnections.passwordHash, identity.passwordHash), eq(scannerInboxConnections.active, true))).for("share") - if (!active) throw scannerError(401, "Scanner-Zugang ungültig.") + await assertWritable(tx, identity) // Serialise retries across processes; a lost HTTP response must not duplicate a document. await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${`${identity.tenantId}:${filename}`}, 0))`) @@ -178,7 +199,7 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc throw error } } - return { connection, credentials, disable, authenticate, find, list, filingOptions, fileDocument, accept } + return { connection, credentials, disable, authenticate, find, list, filingOptions, fileDocument, probe, accept } } export type ScannerInboxService = ReturnType diff --git a/backend/src/routes/scannerInboxGateway.ts b/backend/src/routes/scannerInboxGateway.ts index 4d5dce0c..e868f018 100644 --- a/backend/src/routes/scannerInboxGateway.ts +++ b/backend/src/routes/scannerInboxGateway.ts @@ -45,6 +45,10 @@ export default async function scannerInboxGateway(server: FastifyInstance, optio throw scannerError(412, "Änderungen an vorhandenen Dokumenten werden nicht unterstützt.") } const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0) + if (!body.length && req.headers["content-length"] === "0") { + await service.probe(identity, filename, req.headers["if-none-match"] === "*") + return reply.code(201).send() + } const result = await service.accept(identity, filename, body, req.headers["if-none-match"] === "*") return reply.header("ETag", `"${result.checksum}"`).code(result.created ? 201 : 204).send() } diff --git a/backend/tests/scannerInbox.test.ts b/backend/tests/scannerInbox.test.ts index 0a7eb483..22c8e0fd 100644 --- a/backend/tests/scannerInbox.test.ts +++ b/backend/tests/scannerInbox.test.ts @@ -38,16 +38,18 @@ test("scanner formats are verified independently of the supplied content type", const protocolFixture = async () => { const calls: any[] = [] + const probes: any[] = [] const service = { authenticate: async (username: string, password: string) => username === "scanner" && password === "secret" ? { tenantId: 7 } : null, accept: async (...args: any[]) => { calls.push(args); validateScannerFile(args[1], args[2]); return { created: true, checksum: "abc" } }, + probe: async (...args: any[]) => { probes.push(args) }, find: async () => null, } as unknown as ScannerInboxService const app = Fastify() await app.register(gateway, { prefix: "/scanner-inbox", service }) // Match production: the global CORS plugin is registered after the scanner gateway. await app.register(corsPlugin) - return { app, calls } + return { app, calls, probes } } test("WebDAV authentication happens before body parsing, including discovery requests", async t => { @@ -118,6 +120,23 @@ test("Epson's extra separator does not turn filename checks into false collision assert.equal(calls.length, 2) }) +test("Epson's empty PUT is a write probe, followed by the actual document upload", async t => { + const { app, calls, probes } = await protocolFixture() + t.after(() => app.close()) + const url = "/scanner-inbox//scan.pdf" + const probe = await app.inject({ method: "PUT", url, headers: { ...basic(), "content-length": "0" } }) + assert.equal(probe.statusCode, 201, probe.body) + assert.equal(calls.length, 0) + assert.equal(probes.length, 1) + assert.equal(probes[0][0].tenantId, 7) + assert.equal(probes[0][1], "scan.pdf") + const upload = await app.inject({ method: "PUT", url, headers: basic(), payload: samplePdf }) + assert.equal(upload.statusCode, 201) + assert.equal(calls.length, 1) + assert.deepEqual(calls[0][2], samplePdf) + assert.equal(probes.length, 1) +}) + test("raw PDFs reach storage unchanged and scanner credentials cannot download or delete", async t => { const { app, calls } = await protocolFixture() t.after(() => app.close()) @@ -214,6 +233,20 @@ test("scanner inbox integration: migration, persistence, isolation, retries and doc.addPage() const pdf = Buffer.from(await doc.save()) const send = (name: string, payload = pdf, headers: Record = basic(first.username, first.password)) => app.inject({ method: "PUT", url: `/scanner-inbox/${encodeURIComponent(name)}`, headers: { "content-type": "application/pdf", ...headers }, payload }) + const probe = (name: string, headers: Record = basic(first.username, first.password)) => + app.inject({ method: "PUT", url: `/scanner-inbox/${encodeURIComponent(name)}`, headers: { "content-length": "0", ...headers } }) + + await t.test("empty write probes do not create documents, receipts or storage objects", async () => { + for (let attempt = 0; attempt < 2; attempt++) assert.equal((await probe("scan.pdf")).statusCode, 201) + assert.equal((await probe("scan.pdf", basic(second.username, second.password))).statusCode, 201) + assert.equal((await probe("invalid.zip")).statusCode, 415) + assert.equal((await probe("sub/scan.pdf")).statusCode, 400) + assert.equal((await probe("unauthenticated.pdf", {})).statusCode, 401) + assert.equal(stored.size, 0) + assert.equal((await pool!.query("SELECT count(*) FROM files")).rows[0].count, "0") + assert.equal((await pool!.query("SELECT count(*) FROM scanner_inbox_documents")).rows[0].count, "0") + assert.equal((await app.inject({ method: "HEAD", url: "/scanner-inbox/scan.pdf", headers: basic(first.username, first.password) })).statusCode, 404) + }) await t.test("stores one unchanged two-page PDF as one document without extraction", async () => { const res = await send("scan.pdf") @@ -236,6 +269,10 @@ test("scanner inbox integration: migration, persistence, isolation, retries and assert.equal(download.statusCode, 405) assert.ok(!(download.headers.allow as string).split(/,\s*/).includes("GET")) assert.notDeepEqual(download.rawPayload, pdf) + assert.equal((await probe("scan.pdf")).statusCode, 409) + assert.equal((await probe("scan.pdf", { ...basic(first.username, first.password), "if-none-match": "*" })).statusCode, 412) + assert.deepEqual(stored.get(row.path), pdf) + assert.equal(stored.size, 1) }) await t.test("serialises simultaneous retries and preserves existing content on filename conflicts", async () => { const retry = await Promise.all([send("scan.pdf"), send("scan.pdf")]) @@ -350,6 +387,7 @@ test("scanner inbox integration: migration, persistence, isolation, retries and await t.test("maintenance, portal access and administrator-only settings are enforced", async () => { await pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1") assert.equal((await send("maintenance.pdf")).statusCode, 423) + assert.equal((await probe("maintenance.pdf")).statusCode, 423) await pool.query("UPDATE tenants SET locked=null WHERE id=1") assert.equal((await app.inject({ method: "GET", url: "/api/document-inbox", headers: { "x-test-tenant": "0" } })).statusCode, 400) for (const [method, url] of [["GET", "/api/document-inbox/scanner"], ["POST", "/api/document-inbox/scanner/credentials"], ["DELETE", "/api/document-inbox/scanner"]] as const) { @@ -364,12 +402,14 @@ test("scanner inbox integration: migration, persistence, isolation, retries and const oldIdentity = await service.authenticate(first.username, first.password) assert.equal((await app.inject({ method: "DELETE", url: "/api/document-inbox/scanner" })).statusCode, 204) assert.equal((await send("disabled.pdf")).statusCode, 401) + assert.equal((await probe("disabled.pdf")).statusCode, 401) const rotated = await app.inject({ method: "POST", url: "/api/document-inbox/scanner/credentials" }) assert.equal(rotated.statusCode, 200) assert.equal(rotated.headers["cache-control"], "no-store") assert.equal(rotated.json().connection.username, first.username) assert.equal((await send("old.pdf")).statusCode, 401) await assert.rejects(service.accept(oldIdentity!, "late.pdf", pdf), { statusCode: 401 }) + await assert.rejects(service.probe(oldIdentity!, "late.pdf"), { statusCode: 401 }) assert.equal((await send("new.pdf", pdf, basic(first.username, rotated.json().connection.password))).statusCode, 201) assert.equal((await service.list(1, 50, 0, "")).total, 2) }) diff --git a/docs/scanner-dokumentenablage.md b/docs/scanner-dokumentenablage.md index 408d5e8a..ada56c23 100644 --- a/docs/scanner-dokumentenablage.md +++ b/docs/scanner-dokumentenablage.md @@ -24,6 +24,8 @@ Die vollständige Adresse wird aus der konfigurierten Backend-Adresse gebildet, Grundlage sind die Epson-Handbücher für [ES-580W](https://download.epson.de/pub/download/6371/epson637123eu.pdf) und [ES-C380W](https://download4.epson.biz/sec_pubs/es-c380w/useg/en/GUID-55C5993A-63B2-45C9-B3C9-9C6D6801CDF0.htm). Am physischen ES-580W wurde eine anfängliche `GET`-Prüfung des Empfangsordners beobachtet: Auf die `401`-Anmeldeaufforderung folgt ein authentifiziertes `GET`. Dieses muss erfolgreich beantwortet werden. Danach prüft Epson den Dateinamen per `HEAD`. Bei einer Zieladresse mit abschließendem Schrägstrich hängt das Gerät einen weiteren Trenner an, beispielsweise `/scanner-inbox//scan.pdf`. Der Endpunkt behandelt diese Adresse wie `/scanner-inbox/scan.pdf`; echte Unterordner und ungültige Dateinamen bleiben gesperrt. Ein fälschlich mit `400` beantworteter HEAD-Test führte am Gerät zur Meldung „Dateiname bereits verwendet“. Einen vollständigen Scan bis zum Eintrag in der Dokumentenablage bei der Inbetriebnahme prüfen. +Nach der Dateinamenprüfung sendet der ES-580W zunächst einen leeren `PUT` mit `Content-Length: 0`. Dieser wird als Schreibprobe mit `201` bestätigt, sofern Zugang, Mandant und Dateiname zulässig sind. Dabei entstehen weder eine Datei noch ein Ablageeintrag oder eine dauerhafte Namensreservierung. Erst ein anschließender vollständiger Dokument-Upload wird gespeichert. Vorhandene Dateinamen werden auch bei einer leeren Probe mit `409` bzw. bei `If-None-Match: *` mit `412` geschützt. + ## Schnittstelle und Betrieb - Datenbankmigrationen `0073_scanner_document_inbox` und `0074_scanner_inbox_filing` werden über die reguläre Drizzle-Migration eingespielt. Keine neuen Umgebungsvariablen oder zusätzlichen Dienste sind erforderlich; der vorhandene S3-kompatible Dateispeicher wird genutzt. @@ -31,7 +33,7 @@ Grundlage sind die Epson-Handbücher für [ES-580W](https://download.epson.de/pu - Unterstützt werden `OPTIONS`, `PROPFIND` (Depth 0/1), `HEAD` und `PUT /scanner-inbox/`. Ein authentifiziertes `GET` auf den Empfangsordner (mit oder ohne abschließenden Schrägstrich) liefert für die Epson-Vorprüfung `200` mit leerem Inhalt. Dokumente lassen sich damit nicht herunterladen. Der Empfangsordner existiert bereits; Unterordner, Lesen von Dateien, Löschen, Umbenennen und Überschreiben über den Scanner-Zugang sind nicht vorgesehen. Dies ist ein begrenzter WebDAV-Upload-Eingang, kein allgemeines Netzlaufwerk. Ein PROPFIND des Ordners gibt keine Dokumentenliste preis. - `PUT` enthält genau eine vollständige Datei als Rohdaten, ohne Multipart-Formular. PDF, JPEG, PNG und TIFF werden anhand von Dateiendung und Signatur geprüft, maximal 100 MiB. Keine Prüfung der gesamten Dokumentstruktur auf Beschädigungen. - Erfolgreiches Speichern: `201`. Identische Wiederholung unter demselben Dateinamen: `204`, ohne zweiten Ablageeintrag. Anderer Inhalt unter demselben Namen: `409`; mit `If-None-Match: *` bei vorhandenem Namen: `412`. Gleicher Inhalt unter einem neuen Namen wird als weiteres Dokument akzeptiert. -- Fehlende/ungültige/deaktivierte Zugangsdaten: `401`; leerer Upload/ungültiger Pfad: `400`; zu groß: `413`; falsches Format: `415`; Mandantenwartung: `423`. Bei Speicherfehlern gibt es keine Erfolgsbestätigung und keinen unvollständigen Ablageeintrag. Der Scanner kann erneut senden. +- Fehlende/ungültige/deaktivierte Zugangsdaten: `401`; ungültiger Pfad oder leerer Upload ohne explizite Schreibprobe (`Content-Length: 0`): `400`; zu groß: `413`; falsches Format: `415`; Mandantenwartung: `423`. Bei Speicherfehlern gibt es keine Erfolgsbestätigung und keinen unvollständigen Ablageeintrag. Der Scanner kann erneut senden. - Der Reverse Proxy muss die genannten HTTP-Methoden, den Authorization-Header und Uploads bis 100 MiB zulassen. Bei einem abgeschnittenen Pfadpräfix sollte er `X-Forwarded-Prefix` setzen (Traefik StripPrefix erledigt dies). HTTPS endet üblicherweise am bestehenden Proxy. - Die Browser-API unter `/api/document-inbox` erfordert die normale FEDEO-Anmeldung und einen aktiven Mandanten. Portalbenutzer sind ausgeschlossen. Scanner-Zugänge verwalten nur Administratoren. Die Originale sind außerdem über die bestehende Dateiverwaltung zugänglich. - `GET /api/document-inbox/filing-options` liefert verfügbare Ordner und Typen. `POST /api/document-inbox/:id/file` mit JSON `{ "folder": "", "type": "" }` klassifiziert das Original und markiert den Eingang atomar als abgelegt. Für die Hauptebene ist `folder` als JSON-`null` zu senden. Ein bereits abgelegtes Dokument ergibt `409`; zwischenzeitlich archivierte oder fremde Ziele werden abgewiesen. Dateiänderungen enthalten Zeitpunkt und Benutzer.