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