From fa7de62707c7a912dc6211425dfce98c8e3bf3d7 Mon Sep 17 00:00:00 2001 From: maikrolf <40924921+maikrolf@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:36:54 +0200 Subject: [PATCH] fix: /ipfs/ served alles als application/octet-stream - detectContentType(cid) ignoreerde zijn input en gaf altijd octet-stream terug, terwijl de commentaar claimde dat Kubo het echte type bepaalt - De response draagt nu het Content-Type dat Kubo cat terugstuurt - resolveContentType() valt terug op application/octet-stream bij ontbrekend, leeg of ongeldig type; het patroon weigert CR/LF en quotes zodat upstream- headers niet in de response gespiegeld kunnen worden - Streaming ongewijzigd (body nog volledig in geheugen), headers idem - Nieuwe route-tests: content-type-passthrough, fallbackgevallen, Kubo-aanroep, body, overige headers en de ongewijzigde foutpaden (400/404/501/502) --- src/app/ipfs/[cid]/__tests__/route.test.ts | 180 +++++++++++++++++++++ src/app/ipfs/[cid]/route.ts | 27 +++- 2 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 src/app/ipfs/[cid]/__tests__/route.test.ts diff --git a/src/app/ipfs/[cid]/__tests__/route.test.ts b/src/app/ipfs/[cid]/__tests__/route.test.ts new file mode 100644 index 0000000..afa859d --- /dev/null +++ b/src/app/ipfs/[cid]/__tests__/route.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { NextRequest } from 'next/server'; +import { GET } from '../route'; + +/* ── GET /ipfs/ ── + * + * Audit: `detectContentType(cid)` gaf onafhankelijk van de input altijd + * `application/octet-stream` terug, terwijl de commentaar claimde dat Kubo het + * echte type bepaalt. Content die als octet-stream geserved wordt, kan de + * browser niet als image/audio/video of tekst tonen. + * + * Deze tests dwingen af dat de response het content-type van de upstream + * (`cat`) doorgeeft, met `application/octet-stream` als fallback. + */ + +const CID = 'QmTestCID000'; + +function makeReq(query = ''): NextRequest { + return new NextRequest(`http://localhost/ipfs/${CID}${query}`); +} + +function call(query = '') { + return GET(makeReq(query), { params: Promise.resolve({ cid: CID }) }); +} + +/** Upstream-stand-in: `cat` levert bytes + een Content-Type. */ +function kuboReturning(contentType: string | null, body = 'hallo wereld'): Response { + const headers: Record = {}; + if (contentType !== null) headers['Content-Type'] = contentType; + // Uint8Array-body zodat Response zelf geen Content-Type zet. + const bytes = new TextEncoder().encode(body); + return new Response(bytes, { status: 200, headers }); +} + +const kuboFetch = vi.fn(async (_url: string, _init: RequestInit) => + kuboReturning('text/plain; charset=utf-8'), +); + +beforeEach(() => { + vi.clearAllMocks(); + process.env.KUBO_API_URL = 'http://kubo.test:5001'; + vi.stubGlobal('fetch', kuboFetch); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.KUBO_API_URL; +}); + +describe('content-type van de gateway', () => { + it('geeft het content-type van de upstream door', async () => { + kuboFetch.mockResolvedValueOnce(kuboReturning('text/html; charset=utf-8')); + + const res = await call(); + + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe('text/html; charset=utf-8'); + }); + + it('geeft text/plain door, inclusief charset', async () => { + kuboFetch.mockResolvedValueOnce(kuboReturning('text/plain; charset=utf-8')); + + const res = await call(); + + expect(res.headers.get('Content-Type')).toBe('text/plain; charset=utf-8'); + }); + + it('geef application/json door zonder charset-params', async () => { + kuboFetch.mockResolvedValueOnce(kuboReturning('application/json')); + + const res = await call(); + + expect(res.headers.get('Content-Type')).toBe('application/json'); + }); + + it('valt terug op application/octet-stream als upstream geen type stuurt', async () => { + kuboFetch.mockResolvedValueOnce(kuboReturning(null)); + + const res = await call(); + + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe('application/octet-stream'); + }); + + it('valt terug op application/octet-stream bij een lege content-type', async () => { + kuboFetch.mockResolvedValueOnce(kuboReturning('')); + + const res = await call(); + + expect(res.headers.get('Content-Type')).toBe('application/octet-stream'); + }); + + it('valt terug op application/octet-stream bij een ongeldige content-type', async () => { + kuboFetch.mockResolvedValueOnce(kuboReturning('geen-media-type')); + + const res = await call(); + + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe('application/octet-stream'); + }); + + it('raakt Kubo via POST /api/v0/cat met de CID als arg', async () => { + await call(); + + expect(kuboFetch).toHaveBeenCalledTimes(1); + const [url, init] = kuboFetch.mock.calls[0]; + expect(url).toBe(`http://kubo.test:5001/api/v0/cat?arg=${CID}`); + expect(init.method).toBe('POST'); + }); + + it('geeft de body byte-voor-byte door', async () => { + const payload = '{"hallo":"wereld"}'; + kuboFetch.mockResolvedValueOnce(kuboReturning('application/json', payload)); + + const res = await call(); + + expect(await res.text()).toBe(payload); + }); +}); + +describe('overige gateway-headers', () => { + it('zet Content-Disposition inline zonder filename', async () => { + const res = await call(); + expect(res.headers.get('Content-Disposition')).toBe('inline'); + }); + + it('zet Content-Disposition met de gevraagde filename', async () => { + const res = await call('?filename=rap.txt'); + expect(res.headers.get('Content-Disposition')).toBe('inline; filename="rap.txt"'); + }); + + it('sluit het response aan op immutable caching', async () => { + const res = await call(); + expect(res.headers.get('Cache-Control')).toBe('public, max-age=31536000, immutable'); + }); +}); + +describe('foutpaden blijven ongewijzigd', () => { + it('weigert een ongeldig CID formaat zonder Kubo te raken', async () => { + const res = await GET(new NextRequest('http://localhost/ipfs/niet-een-cid'), { + params: Promise.resolve({ cid: 'niet-een-cid' }), + }); + + expect(res.status).toBe(400); + expect(kuboFetch).not.toHaveBeenCalled(); + }); + + it('geeft een Kubo-foutstatus door', async () => { + kuboFetch.mockResolvedValueOnce( + new Response('not found', { status: 404, headers: { 'Content-Type': 'text/plain' } }), + ); + + const res = await call(); + + expect(res.status).toBe(404); + }); + + it('geeft 501 zonder KUBO_API_URL', async () => { + delete process.env.KUBO_API_URL; + + const res = await call(); + + expect(res.status).toBe(501); + expect(kuboFetch).not.toHaveBeenCalled(); + }); + + it('geeft 502 wanneer de fetch faalt', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('connect ECONNREFUSED'); + }), + ); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + const res = await call(); + + expect(res.status).toBe(502); + }); +}); diff --git a/src/app/ipfs/[cid]/route.ts b/src/app/ipfs/[cid]/route.ts index 13f5f54..dce6df2 100644 --- a/src/app/ipfs/[cid]/route.ts +++ b/src/app/ipfs/[cid]/route.ts @@ -50,8 +50,9 @@ export async function GET( ); } - // Bepaal content type op basis van CID prefix - const contentType = detectContentType(cid); + // Content-type van de upstream overnemen: `cat` stuurt het echte media type + // mee. Zonder dat zou de browser tekst/images/audio niet kunnen tonen. + const contentType = resolveContentType(response.headers.get('Content-Type')); // Check of filename is opgegeven const filename = req.nextUrl.searchParams.get('filename'); @@ -73,11 +74,23 @@ export async function GET( } } +/** Content-type als Kubo `cat` er geen bruikbaar type voor terugstuurt. */ +const DEFAULT_CONTENT_TYPE = 'application/octet-stream'; + /** - * Detecteer content type op basis van CID prefix. - * IPFS content is meestal JSON, tenzij het een bekend formaat is. + * Voldoet aan RFC 9110 §5.6: `type "/" subtype *( ";" parameter )`, met tokens + * uit de tmfh-token-characters. Alles daarbuiten (CR/LF, quotes, spaties) + * wordt geweigerd zodat upstream-headers nooit in onze response worden gespiegeld. */ -function detectContentType(cid: string): string { - // Voor nu: standaard octet-stream, KUBO bepaalt het echte type - return 'application/octet-stream'; +const CONTENT_TYPE_PATTERN = + /^[A-Za-z0-9!#$%&'*+.^_`|~-]+\/[A-Za-z0-9!#$%&'*+.^_`|~-]+(\s*;\s*[A-Za-z0-9!#$%&'*+.^_`|~-]+=(?:[A-Za-z0-9!#$%&'*+.^_`|~-]+|"(?:[A-Za-z0-9!#$%&'*+.^_`|~-]|\s)*"))*$/; + +/** + * Neem het content-type van de upstream over, met `application/octet-stream` + * als fallback wanneer upstream niets bruikbaars stuurt. + */ +function resolveContentType(upstream: string | null): string { + const value = upstream?.trim(); + if (!value) return DEFAULT_CONTENT_TYPE; + return CONTENT_TYPE_PATTERN.test(value) ? value : DEFAULT_CONTENT_TYPE; }