feat: add csrf
This commit is contained in:
@@ -22,8 +22,10 @@ VAULT_MOUNT_POINT=dev-secrets
|
||||
VAULT_SECRET_PATH=database
|
||||
VAULT_JWT_KID_PATH=jwt/kid
|
||||
VAULT_JWT_KIDS_PREFIX=jwt/kids
|
||||
VAULT_CSRF_SECRET_PATH=cryptowallet/csrf
|
||||
|
||||
# CSRF
|
||||
# CSRF (min 32 chars if not using Vault CSRF path)
|
||||
CSRF_SECRET_KEY=change-me-to-at-least-32-chars-long!!
|
||||
CSRF_COOKIE_SECURE=false
|
||||
CSRF_COOKIE_HTTPONLY=true
|
||||
CSRF_COOKIE_SAMESITE=Lax
|
||||
|
||||
4595
apps/api/package-lock.json
generated
Normal file
4595
apps/api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,10 @@ import swaggerUi from 'swagger-ui-express';
|
||||
import { env } from './config/env';
|
||||
import { swaggerSpec } from './config/swagger';
|
||||
import { traceMiddleware } from './middleware/trace';
|
||||
import { csrfProtect } from './middleware/csrf';
|
||||
import { authMiddleware } from './middleware/auth';
|
||||
import { errorHandler } from './middleware/error-handler';
|
||||
import csrfRoutes from './routes/csrf.routes';
|
||||
import walletRoutes from './routes/wallet.routes';
|
||||
import relayProxyRoutes from './routes/relay-proxy.routes';
|
||||
import tronProxyRoutes from './routes/tron-proxy.routes';
|
||||
@@ -34,14 +36,16 @@ app.get('/api/docs/swagger.json', (_req, res) => {
|
||||
res.json(swaggerSpec);
|
||||
});
|
||||
|
||||
app.use('/api/csrf', csrfRoutes);
|
||||
|
||||
// ── PROTECTED endpoints (JWT required) ────────────────────────────────────────
|
||||
app.use('/api/wallets', authMiddleware, walletRoutes);
|
||||
app.use('/api/relay', authMiddleware, relayProxyRoutes);
|
||||
app.use('/api/tron', authMiddleware, tronProxyRoutes);
|
||||
app.use('/api/sol/swap', authMiddleware, solSwapProxyRoutes);
|
||||
app.use('/api/tron/swap', authMiddleware, tronSwapProxyRoutes);
|
||||
app.use('/api/btc', authMiddleware, btcProxyRoutes);
|
||||
app.use('/api/bsc/swap', authMiddleware, bscSwapProxyRoutes);
|
||||
app.use('/api/wallets', csrfProtect, authMiddleware, walletRoutes);
|
||||
app.use('/api/relay', csrfProtect, authMiddleware, relayProxyRoutes);
|
||||
app.use('/api/tron', csrfProtect, authMiddleware, tronProxyRoutes);
|
||||
app.use('/api/sol/swap', csrfProtect, authMiddleware, solSwapProxyRoutes);
|
||||
app.use('/api/tron/swap', csrfProtect, authMiddleware, tronSwapProxyRoutes);
|
||||
app.use('/api/btc', csrfProtect, authMiddleware, btcProxyRoutes);
|
||||
app.use('/api/bsc/swap', csrfProtect, authMiddleware, bscSwapProxyRoutes);
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export let env = {
|
||||
secretPath: p.VAULT_SECRET_PATH || 'database',
|
||||
jwtKidPath: p.VAULT_JWT_KID_PATH || 'jwt/kid',
|
||||
jwtKidsPrefix: p.VAULT_JWT_KIDS_PREFIX || 'jwt/kids',
|
||||
csrfSecretPath: p.VAULT_CSRF_SECRET_PATH || 'cryptowallet/csrf',
|
||||
},
|
||||
csrf: {
|
||||
cookieSecure: p.CSRF_COOKIE_SECURE === 'true',
|
||||
@@ -110,6 +111,13 @@ export async function initEnv(): Promise<void> {
|
||||
|
||||
logger.info('Loaded DB secrets from Vault');
|
||||
|
||||
const maybeCsrf = secrets.CSRF_SECRET_KEY;
|
||||
if (maybeCsrf && maybeCsrf.length >= 32) {
|
||||
const mod = await import('../services/csrf.service');
|
||||
mod.setCsrfSigningKey(maybeCsrf);
|
||||
logger.info('CSRF signing key loaded from Vault (primary secret)');
|
||||
}
|
||||
|
||||
const s = (key: string) => secrets[key];
|
||||
const si = (key: string, fallback: number) => {
|
||||
const v = secrets[key];
|
||||
|
||||
@@ -3,12 +3,15 @@ import knexConfig from './db/knexfile';
|
||||
import app from './app';
|
||||
import { env, initEnv, getVaultToken } from './config/env';
|
||||
import { loadJwtKeysFromVault } from './services/jwt.service';
|
||||
import { loadCsrfSecretFromVault, finalizeCsrfConfigFromEnv } from './services/csrf.service';
|
||||
import { logger } from './lib/logger';
|
||||
|
||||
async function main() {
|
||||
logger.info(`Wallet service instance started with id ${logger.instanceId}`);
|
||||
|
||||
await initEnv();
|
||||
await loadCsrfSecretFromVault();
|
||||
finalizeCsrfConfigFromEnv();
|
||||
|
||||
// Load JWT public keys from Vault if available
|
||||
const vaultToken = getVaultToken();
|
||||
|
||||
23
apps/api/src/middleware/csrf.ts
Normal file
23
apps/api/src/middleware/csrf.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { verifyCsrfPair } from '../services/csrf.service';
|
||||
|
||||
const UNSAFE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
|
||||
|
||||
export async function csrfProtect(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
if (!UNSAFE.has(req.method)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const headerToken = req.get('X-CSRF-Token') ?? undefined;
|
||||
const cookieToken = req.cookies?.csrf_token as string | undefined;
|
||||
|
||||
try {
|
||||
await verifyCsrfPair(cookieToken, headerToken);
|
||||
next();
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'CSRF validation failed';
|
||||
res.status(403).json({ success: false, error: msg });
|
||||
}
|
||||
}
|
||||
41
apps/api/src/routes/csrf.routes.ts
Normal file
41
apps/api/src/routes/csrf.routes.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { env } from '../config/env';
|
||||
import {
|
||||
CSRF_COOKIE_NAME,
|
||||
CSRF_HEADER_NAME,
|
||||
issueCsrfToken,
|
||||
getCsrfCookieMaxAgeMs,
|
||||
} from '../services/csrf.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/token', async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const token = await issueCsrfToken();
|
||||
const c = env.csrf;
|
||||
const sameSiteRaw = (c.cookieSameSite || 'Lax').toLowerCase();
|
||||
const sameSite = (sameSiteRaw === 'strict' || sameSiteRaw === 'none' ? sameSiteRaw : 'lax') as
|
||||
| 'strict'
|
||||
| 'lax'
|
||||
| 'none';
|
||||
res.cookie(CSRF_COOKIE_NAME, token, {
|
||||
secure: c.cookieSecure,
|
||||
httpOnly: c.cookieHttpOnly,
|
||||
sameSite,
|
||||
path: c.cookiePath || '/',
|
||||
maxAge: getCsrfCookieMaxAgeMs(),
|
||||
...(c.cookieDomain ? { domain: c.cookieDomain } : {}),
|
||||
});
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
token,
|
||||
header_name: CSRF_HEADER_NAME,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
115
apps/api/src/services/csrf.service.ts
Normal file
115
apps/api/src/services/csrf.service.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'crypto';
|
||||
import { SignJWT, jwtVerify } from 'jose';
|
||||
import { fetchVaultKV2 } from '../config/vault';
|
||||
import { env, getVaultToken } from '../config/env';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
export const CSRF_COOKIE_NAME = 'csrf_token';
|
||||
|
||||
export const CSRF_HEADER_NAME = 'X-CSRF-Token';
|
||||
|
||||
const TTL_SECONDS = 3600;
|
||||
|
||||
let signingKeyBytes: Uint8Array | null = null;
|
||||
|
||||
|
||||
export function setCsrfSigningKey(secret: string): void {
|
||||
if (secret.length < 32) {
|
||||
throw new Error('CSRF secret must be at least 32 characters');
|
||||
}
|
||||
signingKeyBytes = new Uint8Array(createHash('sha256').update(secret, 'utf8').digest());
|
||||
}
|
||||
|
||||
|
||||
export function hasCsrfSigningKey(): boolean {
|
||||
return signingKeyBytes !== null;
|
||||
}
|
||||
|
||||
|
||||
function getKey(): Uint8Array {
|
||||
if (!signingKeyBytes) {
|
||||
throw new Error('CSRF signing key not configured');
|
||||
}
|
||||
return signingKeyBytes;
|
||||
}
|
||||
|
||||
|
||||
export async function issueCsrfToken(): Promise<string> {
|
||||
const nonce = randomBytes(24).toString('base64url');
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return new SignJWT({ scope: 'csrf', nce: nonce })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt(now)
|
||||
.setNotBefore(now)
|
||||
.setExpirationTime(now + TTL_SECONDS)
|
||||
.sign(getKey());
|
||||
}
|
||||
|
||||
|
||||
export async function verifyCsrfPair(
|
||||
cookieToken: string | undefined,
|
||||
headerToken: string | undefined,
|
||||
): Promise<void> {
|
||||
if (!cookieToken || !headerToken) {
|
||||
const e = new Error('CSRF token missing') as Error & { status: number };
|
||||
e.status = 403;
|
||||
throw e;
|
||||
}
|
||||
const a = Buffer.from(cookieToken);
|
||||
const b = Buffer.from(headerToken);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
const e = new Error('CSRF token mismatch') as Error & { status: number };
|
||||
e.status = 403;
|
||||
throw e;
|
||||
}
|
||||
try {
|
||||
const { payload } = await jwtVerify(cookieToken, getKey(), {
|
||||
algorithms: ['HS256'],
|
||||
clockTolerance: 10,
|
||||
});
|
||||
if (payload.scope !== 'csrf') {
|
||||
throw new Error('invalid');
|
||||
}
|
||||
} catch {
|
||||
const e = new Error('CSRF token invalid') as Error & { status: number };
|
||||
e.status = 403;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function getCsrfCookieMaxAgeMs(): number {
|
||||
return TTL_SECONDS * 1000;
|
||||
}
|
||||
|
||||
|
||||
export async function loadCsrfSecretFromVault(): Promise<boolean> {
|
||||
const token = getVaultToken();
|
||||
if (!token || !env.vault.addr) {
|
||||
return false;
|
||||
}
|
||||
const data = await fetchVaultKV2(env.vault.addr, token, env.vault.mount, env.vault.csrfSecretPath);
|
||||
const key = data?.CSRF_SECRET_KEY;
|
||||
if (!key || key.length < 32) {
|
||||
logger.warn('Vault CSRF secret missing or too short');
|
||||
return false;
|
||||
}
|
||||
setCsrfSigningKey(key);
|
||||
logger.info('CSRF signing key loaded from Vault');
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
export function finalizeCsrfConfigFromEnv(): void {
|
||||
if (hasCsrfSigningKey()) {
|
||||
return;
|
||||
}
|
||||
const k = process.env.CSRF_SECRET_KEY;
|
||||
if (k && k.length >= 32) {
|
||||
setCsrfSigningKey(k);
|
||||
logger.info('CSRF signing key loaded from environment');
|
||||
return;
|
||||
}
|
||||
logger.error('CSRF_SECRET_KEY is required (Vault path or env, min 32 characters)');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -47,6 +47,34 @@
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/csrf/token": {
|
||||
"get": {
|
||||
"summary": "Issue CSRF token (sets cookie, returns token for X-CSRF-Token header)",
|
||||
"tags": ["System"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "CSRF token issued",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": { "type": "boolean", "example": true },
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": { "type": "string" },
|
||||
"header_name": { "type": "string", "example": "X-CSRF-Token" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/health": {
|
||||
"get": {
|
||||
"summary": "Health check",
|
||||
|
||||
Reference in New Issue
Block a user