feat: require a write token or session on mutating portal API routes

- Adds IPFS_WRITE_TOKEN to lib/config.ts
- Default-deny per route (not per method): the public explorer issues reads as POST
- Existing portal session JWT remains valid, so the logged-in UI keeps working
- Public gateway reads (/ipfs/<cid>) are unchanged
This commit is contained in:
maikrolf
2026-09-30 21:10:21 +02:00
parent d0632d4dcc
commit 88f2c7e5aa
6 changed files with 520 additions and 0 deletions
+7
View File
@@ -22,3 +22,10 @@
# IPFS Gateway (for share links) # IPFS Gateway (for share links)
# NEXT_PUBLIC_GATEWAY_URL=https://ipfs.maos.dedyn.io # 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/<cid>, /api/pins/<cid>).
# 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=
+52
View File
@@ -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/<cid>` | DELETE | ✅ |
| `/api/pins` | POST | ✅ |
| `/api/pins/<cid>` | DELETE | ✅ |
| `/api/ipns/publish`, `/api/ipns/keys/gen/<naam>` | POST | ✅ |
| `/api/users/<naam>` | DELETE | ✅ |
| `/api/explorer/{ls,cat,stat,resolve}/<cid>` | POST | ❌ lees-only |
| `/api/pins`, `/api/files`, `/api/explorer/*`, `/api/health` | GET | ❌ publiek |
| `/ipfs/<cid>` (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) ## Smart Contract (Chain 270 — zkSync Local)
**Address**: `0xCBc6b8aeea129c206F4836799621C833Bf8B9BDe` **Address**: `0xCBc6b8aeea129c206F4836799621C833Bf8B9BDe`
@@ -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<string, string>;
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/<cid> → 401', async () => {
const res = await call(DELETE, 'pins/QmTest', { method: 'DELETE' });
expect(res.status).toBe(401);
});
it('DELETE /api/files/<cid> → 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/<name>... 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/<cid> zonder token → 200', async () => {
const res = await call(GET, 'explorer/ls/QmTest', { method: 'GET' });
expect(res.status).toBe(200);
});
it('POST /api/explorer/cat/<cid> (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/<cid> 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);
});
});
+12
View File
@@ -8,6 +8,9 @@
* /api/* → IPFS Kubo API * /api/* → IPFS Kubo API
* *
* Leest X-Session-Token uit JWT cookie voor authenticated requests. * 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'; import { NextRequest, NextResponse } from 'next/server';
@@ -15,6 +18,7 @@ import type { RouteMatch } from './types';
import { proxyUserAPI } from './proxy-user'; import { proxyUserAPI } from './proxy-user';
import { proxyIPFS } from './proxy-ipfs'; import { proxyIPFS } from './proxy-ipfs';
import { handleHealth, proxyResult } from './handlers'; import { handleHealth, proxyResult } from './handlers';
import { authorizeWrite } from './write-auth';
export { mapToKuboAPI } from './proxy-ipfs'; export { mapToKuboAPI } from './proxy-ipfs';
@@ -80,6 +84,10 @@ export async function POST(
const route = matchRoute(path, 'POST'); const route = matchRoute(path, 'POST');
if (!route) return NextResponse.json({ error: 'Not found' }, { status: 404 }); 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) { switch (route.target) {
case 'user': case 'user':
return proxyResult(await proxyUserAPI(route.path, 'POST', req.body, req)); return proxyResult(await proxyUserAPI(route.path, 'POST', req.body, req));
@@ -98,6 +106,10 @@ export async function DELETE(
const route = matchRoute(path, 'DELETE'); const route = matchRoute(path, 'DELETE');
if (!route) return NextResponse.json({ error: 'Not found' }, { status: 404 }); 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) { switch (route.target) {
case 'user': case 'user':
return proxyResult(await proxyUserAPI(route.path, 'DELETE', null, req)); return proxyResult(await proxyUserAPI(route.path, 'DELETE', null, req));
+164
View File
@@ -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 <IPFS_WRITE_TOKEN>` — 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/<name>) 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<boolean> {
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<NextResponse | null> {
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' } },
);
}
+10
View File
@@ -47,6 +47,16 @@ export const paymentContractAddress = (): string =>
export const gatewayUrl = (): string => export const gatewayUrl = (): string =>
process.env.NEXT_PUBLIC_GATEWAY_URL || "https://ipfs.maos.dedyn.io"; 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 ── // ── Composite helpers ──
/** Resolve zkSync chain RPC preferring the public (NEXT_PUBLIC_) value. */ /** Resolve zkSync chain RPC preferring the public (NEXT_PUBLIC_) value. */