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:
@@ -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=
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user