Compare commits

...

2 Commits

Author SHA1 Message Date
517df542e1 feat: add csrf 2026-04-19 11:32:47 +03:00
17855ecd87 chore: add gitignore 2026-04-19 11:32:27 +03:00
10 changed files with 4863 additions and 8 deletions

View File

@@ -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

36
.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
node_modules
.pnpm-store
.pnpm-debug.log*
.turbo
dist
build
out
.next
.nuxt
.cache
.parcel-cache
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
coverage
*.lcov
.nyc_output
*.tsbuildinfo
.env
.env.*
!.env.example
.DS_Store
Thumbs.db
.idea
*.swp
*.swo
*~

4595
apps/api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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);

View File

@@ -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];

View File

@@ -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();

View 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 });
}
}

View 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;

View 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);
}

View File

@@ -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",