From 08abfef8e2ae39f1f2eeedc1d95dca8d16b9b160 Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 8 Oct 2026 22:42:31 +0200 Subject: [PATCH] fix(nextcloud-files): WR-09/IN-02 ungueltige Pfadzeichen sind 400, Vorschau ohne SVG - WR-09: ein einzelnes UTF-16-Ersatzzeichen in einem Pfadsegment ist 400 invalidPath (bzw. invalidName) statt eines URIError mit 500; die Weboberflaeche wiederholt keine 4xx - IN-02: Vorschaubilder nur als Rasterbild, image/svg+xml wird wie ein fehlendes Vorschaubild behandelt (CSP-Sandbox und nosniff bleiben) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../nextcloud-files/nextcloud-files.service.spec.ts | 13 +++++++++++++ .../src/nextcloud-files/nextcloud-files.service.ts | 7 ++++++- apps/api/src/nextcloud-files/nextcloud-http.spec.ts | 10 ++++++++++ apps/api/src/nextcloud-files/nextcloud-http.ts | 8 +++++++- apps/web/src/lib/nextcloud-files-upload.test.ts | 3 +++ 5 files changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/api/src/nextcloud-files/nextcloud-files.service.spec.ts b/apps/api/src/nextcloud-files/nextcloud-files.service.spec.ts index 0e08f73..59d089d 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files.service.spec.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files.service.spec.ts @@ -222,6 +222,19 @@ describe('NextcloudFilesService — preview', () => { expect(res.headers).toEqual({}); }); + it('image/svg+xml als Vorschau -> 404, nichts geschrieben (IN-02)', async () => { + const { service } = setup({ + status: 200, + text: '', + headers: { 'content-type': 'image/svg+xml' }, + }); + const res = new FakeRes(); + await expect(service.preview(res as never, 't1', 'u1', '42')).rejects.toMatchObject({ + response: { code: 'notFound' }, + }); + expect(res.written).toHaveLength(0); + }); + it('image/png wird gestreamt; mit Version einen Tag gecacht, ohne eine Stunde', async () => { const png = { status: 200, text: 'PNG', headers: { 'content-type': 'image/png' } }; const withV = setup(png); diff --git a/apps/api/src/nextcloud-files/nextcloud-files.service.ts b/apps/api/src/nextcloud-files/nextcloud-files.service.ts index 6efb45a..6cd8a59 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files.service.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files.service.ts @@ -134,7 +134,12 @@ export class NextcloudFilesService { 'x-content-type-options': 'nosniff', 'content-security-policy': "default-src 'none'; sandbox", }, - accept: (contentType) => contentType.toLowerCase().startsWith('image/'), + // Nur Rasterbilder (IN-02): SVG kann Skript enthalten; CSP-Sandbox und nosniff entschaerfen + // das zwar, aber eine Vorschau braucht kein SVG (Nextcloud rendert Vorschauen als PNG/JPEG). + accept: (contentType) => { + const type = contentType.toLowerCase(); + return type.startsWith('image/') && !type.startsWith('image/svg'); + }, maxBytes: PREVIEW_MAX_BYTES, onExpired: () => this.account.markExpired(tenantId, userId), }); diff --git a/apps/api/src/nextcloud-files/nextcloud-http.spec.ts b/apps/api/src/nextcloud-files/nextcloud-http.spec.ts index 3608e20..de767f2 100644 --- a/apps/api/src/nextcloud-files/nextcloud-http.spec.ts +++ b/apps/api/src/nextcloud-files/nextcloud-http.spec.ts @@ -78,6 +78,16 @@ describe('buildNcUrl / encodeSegments', () => { }); describe('Pfade', () => { + it('ein einzelnes UTF-16-Ersatzzeichen ist invalidPath statt eines URIError (WR-09)', () => { + expect(codeOf(() => validateSegment('a\uD800b'))).toBe('invalidPath'); + expect(codeOf(() => validateSegment('\uDC00'))).toBe('invalidPath'); + expect(codeOf(() => parseUserPath('/ok/\uD83D'))).toBe('invalidPath'); + expect(codeOf(() => validateNewName('x\uDFFF'))).toBe('invalidName'); + // ein vollstaendiges Paar (Emoji) bleibt erlaubt + expect(validateSegment('Foto \uD83D\uDE00.jpg')).toBe('Foto 😀.jpg'); + expect(encodeSegments(['😀'])).toBe('%F0%9F%98%80'); + }); + it('parseUserPath: Wurzel und normale Pfade', () => { expect(parseUserPath('')).toEqual([]); expect(parseUserPath('/')).toEqual([]); diff --git a/apps/api/src/nextcloud-files/nextcloud-http.ts b/apps/api/src/nextcloud-files/nextcloud-http.ts index a452f61..e9a8683 100644 --- a/apps/api/src/nextcloud-files/nextcloud-http.ts +++ b/apps/api/src/nextcloud-files/nextcloud-http.ts @@ -104,9 +104,14 @@ const MAX_SEGMENT_BYTES = 255; const MAX_SEGMENTS = 100; const MAX_PATH_CHARS = 4096; +/** Ein einzelnes UTF-16-Ersatzzeichen ohne Partner (z. B. `"\uD800"` aus einem JSON-Koerper). */ +const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? MAX_SEGMENT_BYTES ) { throw ncErrorDefault('invalidPath'); diff --git a/apps/web/src/lib/nextcloud-files-upload.test.ts b/apps/web/src/lib/nextcloud-files-upload.test.ts index 3924e73..a0cede2 100644 --- a/apps/web/src/lib/nextcloud-files-upload.test.ts +++ b/apps/web/src/lib/nextcloud-files-upload.test.ts @@ -497,6 +497,9 @@ describe('isRetryable', () => { expect(isRetryable(e(503, 'nextcloudMaintenance'))).toBe(false); expect(isRetryable(e(409, 'nameTaken'))).toBe(false); expect(isRetryable(e(413, 'chunkTooLarge'))).toBe(false); + // WR-09: ein ungueltiger Pfad ist jetzt 400 invalidPath — keine 4xx wird wiederholt. + expect(isRetryable(e(400, 'invalidPath'))).toBe(false); + expect(isRetryable(e(400, null))).toBe(false); expect(isRetryable(e(0, 'aborted'))).toBe(false); expect(isRetryable(new Error('x'))).toBe(false); });