diff --git a/.env.example b/.env.example index 35379a6..0724a33 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,10 @@ # IPFS Gateway (for share links) # NEXT_PUBLIC_GATEWAY_URL=https://ipfs.maos.dedyn.io + +# Write-authentication voor muterende /api/* endpoints +# (POST /api/add, /api/files/upload, /api/pins; DELETE /api/files/, /api/pins/). +# Zonder deze waarde blijven writes OPEN met een luide waarschuwing in de log. +# Zelfde waarde als IPFS_WRITE_TOKEN aan de MAOS-kant (anders pint MAOS niet mee). +# Genereer met: openssl rand -hex 32 +# IPFS_WRITE_TOKEN= diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a08ab57..c50f444 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -222,6 +222,58 @@ Catch-all API proxy. Routes: --- +## Write authentication + +Muterende `/api/*`-endpoints eisen een token; lees-endpoints blijven publiek. +Ingang: `authorizeWrite()` in `src/app/api/[...path]/write-auth.ts`, aangeroepen +vanuit `POST` en `DELETE` in `route.ts`. De waarde komt uit +`ipfsWriteToken()` in `src/lib/config.ts` (env `IPFS_WRITE_TOKEN`). + +| Endpoint | Methode | Write-gate | +|---|---|---| +| `/api/add` | POST | ✅ | +| `/api/files/upload` | POST | ✅ | +| `/api/files/` | DELETE | ✅ | +| `/api/pins` | POST | ✅ | +| `/api/pins/` | DELETE | ✅ | +| `/api/ipns/publish`, `/api/ipns/keys/gen/` | POST | ✅ | +| `/api/users/` | DELETE | ✅ | +| `/api/explorer/{ls,cat,stat,resolve}/` | POST | ❌ lees-only | +| `/api/pins`, `/api/files`, `/api/explorer/*`, `/api/health` | GET | ❌ publiek | +| `/ipfs/` (gateway) | GET | ❌ publiek | +| `/api/users` (registratie) | POST | ❌ open — eigen auth van Python-backend | + +**Toegang tot een mutatie — één van beide volstaat:** + +1. `Authorization: Bearer $IPFS_WRITE_TOKEN` (MAOS → portal, server-to-server). + Vergelijking is constant-time via SHA-256 digests (`crypto.timingSafeEqual`). +2. De bestaande portalsessie-JWT cookie `ipfs-portal-session` — identieke + verificatie als `src/middleware.ts`. Zónder dit pad zouden de eigen + upload-/pin-pagina's van de portal kapotgaan. + +Geen van beide → `401` (geen `Authorization`-header) of `403` (header aanwezig, +verkeerde waarde), met `WWW-Authenticate: Bearer`. + +**`IPFS_WRITE_TOKEN` niet geconfigureerd** → writes blijven **open** met een +eenmalige luide waarschuwing in de log. `src/lib/config.ts` heeft geen +"required in production"-patroon (alleen defaults, in tegenstelling tot +`JWT_SECRET` in `auth-server.ts`), en een harde fail zou elke nog niet +geprovisioneerde deploy direct breken — vandaar bewust fail-open + waarschuwing. + +**Besluit — publieke lees-endpoints**: `GET /api/pins` en `GET /api/files` +blijven anoniem toegankelijk. Ze lekken alleen metadata (CID's, namen, maten); +content is sowieso publiek via de gateway. Ze achter de sessie zetten zou de +publieke explorer (pin-badges) én MAOS' pin-status/inventory-reads breken — die +sturen vandaag geen `Authorization`-header op GET. Zodra MAOS ook GET's met +Bearer verstuurt kan dit alsnog achter de gate. + +**Rest-risico**: `POST /api/users` (registratie én admin-aanmaak, zelfde +endpoint) blijft open — een registrant heeft noch token noch sessie. De +Python-backend is daar de autoriteit (`X-Admin-Key` + doorgestuurd +`X-Session-Token`). + +--- + ## Smart Contract (Chain 270 — zkSync Local) **Address**: `0xCBc6b8aeea129c206F4836799621C833Bf8B9BDe` diff --git a/src/app/api/[...path]/__tests__/write-auth.test.ts b/src/app/api/[...path]/__tests__/write-auth.test.ts new file mode 100644 index 0000000..8145bf0 --- /dev/null +++ b/src/app/api/[...path]/__tests__/write-auth.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +/* ── Write-token enforcement tests ── + * + * Audit (2026-09-28): POST /api/add was reachable without any token. + * Deze tests locken het gedrag van de catch-all /api/* route: + * - muterende endpoints → 401/403 zonder token (en zonder sessie) + * - muterende endpoints met correct Bearer-token → handler wél bereikt + * - lees-endpoints → ook zonder token nog steeds 200 + * + * De Kubo/local-afhankelijkheden zijn gemockt zodat tests geen netwerk + * of schijf nodig hebben; alleen de route-handler + gate zelf draait echt. + */ + +vi.mock('@/lib/local-pins', () => ({ + localListPins: vi.fn(async () => []), + localAddPin: vi.fn(async () => ({ cid: 'QmTest' })), + localRemovePin: vi.fn(async () => {}), +})); + +vi.mock('@/lib/local-files', () => ({ + localSaveFile: vi.fn(async () => ({ cid: 'QmTest', size: 1, name: 'a.txt' })), + localGetFile: vi.fn(async () => null), + localListFiles: vi.fn(async () => []), + localDeleteFile: vi.fn(async () => {}), +})); + +import { GET, POST, DELETE } from '../route'; +import { GET as gatewayGET } from '@/app/ipfs/[cid]/route'; +import { createSessionJWT, SESSION_COOKIE } from '@/lib/auth-server'; +import { localAddPin } from '@/lib/local-pins'; + +const WRITE_TOKEN = 'test-write-token-123'; + +/** Kubo-stand-in: elke proxied Kubo-call krijgt een 200 terug. */ +const kuboFetch = vi.fn( + async () => new Response(JSON.stringify({ Hash: 'QmTest', Size: '1' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), +); + +/** Minimale init-shape voor tests — vermijdt DOM/undici RequestInit-conflict. */ +interface TestInit { + method?: string; + headers?: Record; + body?: string; + sessionJwt?: string; +} + +function makeReq(apiPath: string, init: TestInit = {}): NextRequest { + const { sessionJwt, headers = {}, body, method = 'GET' } = init; + const h = new Headers(headers); + if (sessionJwt) h.set('cookie', `${SESSION_COOKIE}=${sessionJwt}`); + return new NextRequest(`http://localhost/api/${apiPath}`, { method, headers: h, body }); +} + +function call( + handler: typeof POST, + apiPath: string, + init: TestInit = {}, +) { + return handler(makeReq(apiPath, init), { + params: Promise.resolve({ path: apiPath.split('/').filter(Boolean) }), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + process.env.IPFS_WRITE_TOKEN = WRITE_TOKEN; + process.env.JWT_SECRET = 'test-jwt-secret'; + process.env.KUBO_API_URL = 'http://kubo.test:5001'; + vi.stubGlobal('fetch', kuboFetch); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.IPFS_WRITE_TOKEN; +}); + +/* ════════════════════════ Niet-geconfigureerde token ════════════════════════ */ + +describe('IPFS_WRITE_TOKEN niet geconfigureerd', () => { + // Moet als eerste: het warning-log is once-per-process. + it('houdt muterende endpoints open en logt een luide waarschuwing', async () => { + delete process.env.IPFS_WRITE_TOKEN; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const first = await call(POST, 'pins', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cid: 'QmTest' }), + }); + const second = await call(POST, 'pins', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cid: 'QmTest' }), + }); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0]?.[0] ?? '')).toMatch(/IPFS_WRITE_TOKEN/); + + warn.mockRestore(); + }); +}); + +/* ════════════════════════ Muterende endpoints — geen token ════════════════════════ */ + +describe('muterende endpoints zonder token', () => { + it('POST /api/pins → 401 en handler niet bereikt', async () => { + const res = await call(POST, 'pins', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cid: 'QmTest' }), + }); + + expect(res.status).toBe(401); + expect(localAddPin).not.toHaveBeenCalled(); + }); + + it('POST /api/add → 401 (audit-endpoint) en Kubo niet aangeroepen', async () => { + const res = await call(POST, 'add', { method: 'POST' }); + + expect(res.status).toBe(401); + expect(kuboFetch).not.toHaveBeenCalled(); + }); + + it('POST /api/files/upload → 401', async () => { + const res = await call(POST, 'files/upload', { method: 'POST' }); + expect(res.status).toBe(401); + }); + + it('DELETE /api/pins/ → 401', async () => { + const res = await call(DELETE, 'pins/QmTest', { method: 'DELETE' }); + expect(res.status).toBe(401); + }); + + it('DELETE /api/files/ → 401', async () => { + const res = await call(DELETE, 'files/QmTest', { method: 'DELETE' }); + expect(res.status).toBe(401); + }); + + it('POST /api/ipns/publish → 401', async () => { + const res = await call(POST, 'ipns/publish', { method: 'POST' }); + expect(res.status).toBe(401); + }); + + it('POST /api/users/... gebruikersverwijdering via DELETE → 401', async () => { + const res = await call(DELETE, 'users/bob', { method: 'DELETE' }); + expect(res.status).toBe(401); + }); +}); + +/* ════════════════════════ Muterende endpoints — token ════════════════════════ */ + +describe('muterende endpoints met token', () => { + it('POST /api/pins met correct Bearer-token → handler bereikt', async () => { + const res = await call(POST, 'pins', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${WRITE_TOKEN}`, + }, + body: JSON.stringify({ cid: 'QmTest' }), + }); + + expect(res.status).toBe(200); + expect(localAddPin).toHaveBeenCalledTimes(1); + }); + + it('POST /api/add met correct Bearer-token → handler bereikt (Kubo-aanroep)', async () => { + const res = await call(POST, 'add', { + method: 'POST', + headers: { Authorization: `Bearer ${WRITE_TOKEN}` }, + }); + + expect(res.status).toBe(200); + expect(kuboFetch).toHaveBeenCalledTimes(1); + }); + + it('POST /api/pins met verkeerd token → 403 en handler niet bereikt', async () => { + const res = await call(POST, 'pins', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer wrong-token', + }, + body: JSON.stringify({ cid: 'QmTest' }), + }); + + expect(res.status).toBe(403); + expect(localAddPin).not.toHaveBeenCalled(); + }); + + it('POST /api/pins met verkeerd token (zelfde lengte) → 403', async () => { + const res = await call(POST, 'pins', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${'x'.repeat(WRITE_TOKEN.length)}`, + }, + body: JSON.stringify({ cid: 'QmTest' }), + }); + + expect(res.status).toBe(403); + }); + + it('POST /api/pins met bestaande sessie-cookie (geen bearer) → handler bereikt', async () => { + const jwt = await createSessionJWT({ + address: '0xabc', + role: 'user', + sessionToken: 'sess-1', + }); + + const res = await call(POST, 'pins', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cid: 'QmTest' }), + sessionJwt: jwt, + }); + + expect(res.status).toBe(200); + expect(localAddPin).toHaveBeenCalledTimes(1); + }); +}); + +/* ════════════════════════ Lees-endpoints — publiek ════════════════════════ */ + +describe('lees-endpoints blijven publiek', () => { + it('GET /api/pins zonder token → 200', async () => { + const res = await call(GET, 'pins', { method: 'GET' }); + expect(res.status).toBe(200); + }); + + it('GET /api/files zonder token → 200', async () => { + const res = await call(GET, 'files', { method: 'GET' }); + expect(res.status).toBe(200); + }); + + it('GET /api/explorer/ls/ zonder token → 200', async () => { + const res = await call(GET, 'explorer/ls/QmTest', { method: 'GET' }); + expect(res.status).toBe(200); + }); + + it('POST /api/explorer/cat/ (read-only POST) zonder token → handler bereikt', async () => { + const res = await call(POST, 'explorer/cat/QmTest', { method: 'POST' }); + expect(res.status).toBe(200); + expect(kuboFetch).toHaveBeenCalledTimes(1); + }); + + it('GET /ipfs/ gateway zonder token → 200', async () => { + const res = await gatewayGET( + new NextRequest('http://localhost/ipfs/QmTestCID000'), + { params: Promise.resolve({ cid: 'QmTestCID000' }) }, + ); + expect(res.status).toBe(200); + }); +}); + +/* ════════════════════════ Uitzondering: registratie ════════════════════════ */ + +describe('registratie blijft bereikbaar', () => { + it('POST /api/users (aanmaken account) zonder token → handler bereikt', async () => { + const res = await call(POST, 'users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'nieuw', password: 'secret1' }), + }); + + expect(res.status).toBe(200); + }); +}); diff --git a/src/app/api/[...path]/route.ts b/src/app/api/[...path]/route.ts index 903d48c..6737b26 100644 --- a/src/app/api/[...path]/route.ts +++ b/src/app/api/[...path]/route.ts @@ -8,6 +8,9 @@ * /api/* → IPFS Kubo API * * Leest X-Session-Token uit JWT cookie voor authenticated requests. + * + * Write-auth: muterende methodes (POST/DELETE) lopen eerst door + * authorizeWrite() — zie write-auth.ts. GET blijft publiek (gateway-reads). */ import { NextRequest, NextResponse } from 'next/server'; @@ -15,6 +18,7 @@ import type { RouteMatch } from './types'; import { proxyUserAPI } from './proxy-user'; import { proxyIPFS } from './proxy-ipfs'; import { handleHealth, proxyResult } from './handlers'; +import { authorizeWrite } from './write-auth'; export { mapToKuboAPI } from './proxy-ipfs'; @@ -80,6 +84,10 @@ export async function POST( const route = matchRoute(path, 'POST'); if (!route) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + // Write-gate: mutaties eisen IPFS_WRITE_TOKEN of portalsessie (zie write-auth.ts) + const denied = await authorizeWrite(req, route); + if (denied) return denied; + switch (route.target) { case 'user': return proxyResult(await proxyUserAPI(route.path, 'POST', req.body, req)); @@ -98,6 +106,10 @@ export async function DELETE( const route = matchRoute(path, 'DELETE'); if (!route) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + // Write-gate: mutaties eisen IPFS_WRITE_TOKEN of portalsessie (zie write-auth.ts) + const denied = await authorizeWrite(req, route); + if (denied) return denied; + switch (route.target) { case 'user': return proxyResult(await proxyUserAPI(route.path, 'DELETE', null, req)); diff --git a/src/app/api/[...path]/write-auth.ts b/src/app/api/[...path]/write-auth.ts new file mode 100644 index 0000000..a49bf32 --- /dev/null +++ b/src/app/api/[...path]/write-auth.ts @@ -0,0 +1,164 @@ +/* ── IPFS Portal Write Auth ── + * + * Audit-fix (2026-09-28): POST /api/add was zonder token bereikbaar. + * Deze module bepaalt of een catch-all /api/* request een MUTATIE is en + * of die zonder credentielegitimatie doorgaat. + * + * Regels: + * 1. Safe methods (GET/HEAD/OPTIONS) → lees, altijd open. + * 2. Lokale mutaties (pins POST/DELETE, files/upload, files DELETE) → mutatie. + * 3. User-API (Python backend) → eigen auth; uitzondering: account-aanmaak + * (POST …/users) want een registrant heeft noch token noch sessie. + * 4. Kubo-pass-through → alleen expliciet lees-only Kubo-endpoints blijven + * open (explorer/cat, ls, stat, resolve, …); de rest is default-deny. + * + * Authorisatie voor een mutatie (één van beide volstaat): + * - `Authorization: Bearer ` — constant-time vergeleken + * (MAOS-server → server-to-server), óf + * - de bestaande portal sessie-JWT cookie (browser, zelfde verificatie als + * src/middleware.ts — géén nieuw auth-systeem). + * + * IPFS_WRITE_TOKEN niet geconfigureerd → toegang OPEN + luide once-per-process + * waarschuwing. `src/lib/config.ts` heeft geen "required in production"-patroon + * (alleen defaults); hard falen zou elke nog niet-geprovisioneerde deploy + * direct breken. Zie ARCHITECTURE.md § Write authentication. + */ + +import { createHash, timingSafeEqual } from 'node:crypto'; +import { NextRequest, NextResponse } from 'next/server'; +import { ipfsWriteToken } from '@/lib/config'; +import { verifySessionJWT, SESSION_COOKIE } from '@/lib/auth-server'; +import { mapToKuboAPI } from './proxy-ipfs'; +import type { RouteMatch } from './types'; + +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +/** + * Kubo-endpoints die uitsluitend lezen. Alles wat hier niet in valt is een + * mutatie en krijgt de write-gate (default-deny). + */ +const READ_ONLY_KUBO_ENDPOINTS = [ + '/api/v0/id', + '/api/v0/version', + '/api/v0/ls', + '/api/v0/cat', + '/api/v0/get', + '/api/v0/refs', + '/api/v0/resolve', + '/api/v0/dns', + '/api/v0/pin/ls', + '/api/v0/key/list', + '/api/v0/name/resolve', + '/api/v0/object/stat', + '/api/v0/object/get', + '/api/v0/repo/stat', + '/api/v0/bw', + '/api/v0/swarm/peers', + '/api/v0/files/ls', + '/api/v0/files/stat', + '/api/v0/block/get', + '/api/v0/dag/get', + '/api/v0/dag/resolve', +]; + +/* ══════════════════════ Classificatie ══════════════════════ */ + +/** True = het request muteert staat en valt onder de write-gate. */ +export function isMutatingRequest(match: RouteMatch): boolean { + if (SAFE_METHODS.has(match.method)) return false; + + const p = match.path.replace(/^\/+/, ''); + + // Lokale pin-opslag (proxy-ipfs handleLocalPins): GET leest, rest muteert. + if (p === 'pins' || p.startsWith('pins/')) return match.method !== 'GET'; + + // Lokale file-opslag (proxy-ipfs): upload + delete muteren. + if (p === 'files/upload') return true; + if (p.startsWith('files/') && match.method === 'DELETE') return true; + + // User-API (Python backend, X-Admin-Key + X-Session-Token) is een eigen + // auth-domein. Account-aanmaak blijft open: registranten hebben noch token + // noch sessie. Alles anders (bv. DELETE /api/users/) valt wel hier. + if (match.target === 'user') { + return !(match.method === 'POST' && p === 'users'); + } + + // Health (inline) muteert niets. + if (match.target !== 'ipfs') return false; + + // Kubo-pass-through: alleen aantoonbaar lees-only operaties blijven open. + const endpoint = mapToKuboAPI(match.path, match.method).split('?')[0]; + return !READ_ONLY_KUBO_ENDPOINTS.some( + (ep) => endpoint === ep || endpoint.startsWith(`${ep}/`), + ); +} + +/* ══════════════════════ Token check ══════════════════════ */ + +/** + * Constant-time vergelijking via SHA-256 digests: gelijke uitvoerduur ongeacht + * inhoud én geen lengte-lek (digests zijn altijd 32 bytes). + */ +function tokensMatch(presented: string, expected: string): boolean { + const a = createHash('sha256').update(presented, 'utf8').digest(); + const b = createHash('sha256').update(expected, 'utf8').digest(); + return timingSafeEqual(a, b); +} + +function bearerMatches(header: string | null, expected: string): boolean { + if (!header) return false; + const match = /^Bearer\s+(\S+)$/i.exec(header.trim()); + if (!match) return false; + return tokensMatch(match[1], expected); +} + +async function hasPortalSession(req: NextRequest): Promise { + const raw = req.cookies.get(SESSION_COOKIE)?.value; + if (!raw) return false; + const session = await verifySessionJWT(raw); + return session !== null; +} + +let warnedMissingToken = false; + +function warnMissingWriteToken(): void { + if (warnedMissingToken) return; + warnedMissingToken = true; + console.warn( + '[write-auth] ⚠️ IPFS_WRITE_TOKEN niet ingesteld — muterende /api/* endpoints staan OPEN. ' + + 'Zet IPFS_WRITE_TOKEN in .env (zie .env.example) en herdeploy om writes af te dwingen.', + ); +} + +/* ══════════════════════ Gate ══════════════════════ */ + +/** + * Controleert een catch-all request. Retourneert een NextResponse bij weigering, + * `null` als het request doorgaat naar de handler. + */ +export async function authorizeWrite( + req: NextRequest, + match: RouteMatch, +): Promise { + if (!isMutatingRequest(match)) return null; + + const configured = ipfsWriteToken(); + if (!configured) { + // Geen token geconfigureerd → open + luide waarschuwing (zie module-doc). + warnMissingWriteToken(); + return null; + } + + if (bearerMatches(req.headers.get('authorization'), configured)) return null; + if (await hasPortalSession(req)) return null; + + const presented = req.headers.get('authorization'); + const status = presented ? 403 : 401; + return NextResponse.json( + { + error: presented ? 'Forbidden' : 'Unauthorized', + detail: 'IPFS_WRITE_TOKEN of geldige portalsessie vereist voor writes', + }, + { status, headers: { 'WWW-Authenticate': 'Bearer' } }, + ); +} diff --git a/src/lib/config.ts b/src/lib/config.ts index c5eaf8d..354d367 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -47,6 +47,16 @@ export const paymentContractAddress = (): string => export const gatewayUrl = (): string => process.env.NEXT_PUBLIC_GATEWAY_URL || "https://ipfs.maos.dedyn.io"; +// ── Write authentication ── +/** + * Bearer-token die verplicht is voor muterende /api/* requests + * (upload/add, pin add/rm, file delete, ipns publish, user delete). + * Lege waarde = writes staan open (met luide waarschuwing in write-auth). + * Server-only — nooit exposure naar de browser bundel. + */ +export const ipfsWriteToken = (): string | undefined => + process.env.IPFS_WRITE_TOKEN; + // ── Composite helpers ── /** Resolve zkSync chain RPC preferring the public (NEXT_PUBLIC_) value. */