feat: add csrf
This commit is contained in:
@@ -22,8 +22,10 @@ VAULT_MOUNT_POINT=dev-secrets
|
|||||||
VAULT_SECRET_PATH=database
|
VAULT_SECRET_PATH=database
|
||||||
VAULT_JWT_KID_PATH=jwt/kid
|
VAULT_JWT_KID_PATH=jwt/kid
|
||||||
VAULT_JWT_KIDS_PREFIX=jwt/kids
|
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_SECURE=false
|
||||||
CSRF_COOKIE_HTTPONLY=true
|
CSRF_COOKIE_HTTPONLY=true
|
||||||
CSRF_COOKIE_SAMESITE=Lax
|
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 { env } from './config/env';
|
||||||
import { swaggerSpec } from './config/swagger';
|
import { swaggerSpec } from './config/swagger';
|
||||||
import { traceMiddleware } from './middleware/trace';
|
import { traceMiddleware } from './middleware/trace';
|
||||||
|
import { csrfProtect } from './middleware/csrf';
|
||||||
import { authMiddleware } from './middleware/auth';
|
import { authMiddleware } from './middleware/auth';
|
||||||
import { errorHandler } from './middleware/error-handler';
|
import { errorHandler } from './middleware/error-handler';
|
||||||
|
import csrfRoutes from './routes/csrf.routes';
|
||||||
import walletRoutes from './routes/wallet.routes';
|
import walletRoutes from './routes/wallet.routes';
|
||||||
import relayProxyRoutes from './routes/relay-proxy.routes';
|
import relayProxyRoutes from './routes/relay-proxy.routes';
|
||||||
import tronProxyRoutes from './routes/tron-proxy.routes';
|
import tronProxyRoutes from './routes/tron-proxy.routes';
|
||||||
@@ -34,14 +36,16 @@ app.get('/api/docs/swagger.json', (_req, res) => {
|
|||||||
res.json(swaggerSpec);
|
res.json(swaggerSpec);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.use('/api/csrf', csrfRoutes);
|
||||||
|
|
||||||
// ── PROTECTED endpoints (JWT required) ────────────────────────────────────────
|
// ── PROTECTED endpoints (JWT required) ────────────────────────────────────────
|
||||||
app.use('/api/wallets', authMiddleware, walletRoutes);
|
app.use('/api/wallets', csrfProtect, authMiddleware, walletRoutes);
|
||||||
app.use('/api/relay', authMiddleware, relayProxyRoutes);
|
app.use('/api/relay', csrfProtect, authMiddleware, relayProxyRoutes);
|
||||||
app.use('/api/tron', authMiddleware, tronProxyRoutes);
|
app.use('/api/tron', csrfProtect, authMiddleware, tronProxyRoutes);
|
||||||
app.use('/api/sol/swap', authMiddleware, solSwapProxyRoutes);
|
app.use('/api/sol/swap', csrfProtect, authMiddleware, solSwapProxyRoutes);
|
||||||
app.use('/api/tron/swap', authMiddleware, tronSwapProxyRoutes);
|
app.use('/api/tron/swap', csrfProtect, authMiddleware, tronSwapProxyRoutes);
|
||||||
app.use('/api/btc', authMiddleware, btcProxyRoutes);
|
app.use('/api/btc', csrfProtect, authMiddleware, btcProxyRoutes);
|
||||||
app.use('/api/bsc/swap', authMiddleware, bscSwapProxyRoutes);
|
app.use('/api/bsc/swap', csrfProtect, authMiddleware, bscSwapProxyRoutes);
|
||||||
|
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export let env = {
|
|||||||
secretPath: p.VAULT_SECRET_PATH || 'database',
|
secretPath: p.VAULT_SECRET_PATH || 'database',
|
||||||
jwtKidPath: p.VAULT_JWT_KID_PATH || 'jwt/kid',
|
jwtKidPath: p.VAULT_JWT_KID_PATH || 'jwt/kid',
|
||||||
jwtKidsPrefix: p.VAULT_JWT_KIDS_PREFIX || 'jwt/kids',
|
jwtKidsPrefix: p.VAULT_JWT_KIDS_PREFIX || 'jwt/kids',
|
||||||
|
csrfSecretPath: p.VAULT_CSRF_SECRET_PATH || 'cryptowallet/csrf',
|
||||||
},
|
},
|
||||||
csrf: {
|
csrf: {
|
||||||
cookieSecure: p.CSRF_COOKIE_SECURE === 'true',
|
cookieSecure: p.CSRF_COOKIE_SECURE === 'true',
|
||||||
@@ -110,6 +111,13 @@ export async function initEnv(): Promise<void> {
|
|||||||
|
|
||||||
logger.info('Loaded DB secrets from Vault');
|
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 s = (key: string) => secrets[key];
|
||||||
const si = (key: string, fallback: number) => {
|
const si = (key: string, fallback: number) => {
|
||||||
const v = secrets[key];
|
const v = secrets[key];
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ import knexConfig from './db/knexfile';
|
|||||||
import app from './app';
|
import app from './app';
|
||||||
import { env, initEnv, getVaultToken } from './config/env';
|
import { env, initEnv, getVaultToken } from './config/env';
|
||||||
import { loadJwtKeysFromVault } from './services/jwt.service';
|
import { loadJwtKeysFromVault } from './services/jwt.service';
|
||||||
|
import { loadCsrfSecretFromVault, finalizeCsrfConfigFromEnv } from './services/csrf.service';
|
||||||
import { logger } from './lib/logger';
|
import { logger } from './lib/logger';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
logger.info(`Wallet service instance started with id ${logger.instanceId}`);
|
logger.info(`Wallet service instance started with id ${logger.instanceId}`);
|
||||||
|
|
||||||
await initEnv();
|
await initEnv();
|
||||||
|
await loadCsrfSecretFromVault();
|
||||||
|
finalizeCsrfConfigFromEnv();
|
||||||
|
|
||||||
// Load JWT public keys from Vault if available
|
// Load JWT public keys from Vault if available
|
||||||
const vaultToken = getVaultToken();
|
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": {
|
"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": {
|
"/health": {
|
||||||
"get": {
|
"get": {
|
||||||
"summary": "Health check",
|
"summary": "Health check",
|
||||||
|
|||||||
Reference in New Issue
Block a user