fix(scanner): accept Epson empty write probes
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m2s
Build and Release FEDEO / build-and-release (push) Successful in 6m0s

This commit is contained in:
2026-09-30 19:57:54 +02:00
parent 2143ce0933
commit f359bfb579
4 changed files with 80 additions and 13 deletions

View File

@@ -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<FastifyInstance["db"], "select">, 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<typeof createScannerInboxService>

View File

@@ -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()
}

View File

@@ -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<string, string> = 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<string, string> = 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)
})