init
This commit is contained in:
@@ -10,7 +10,6 @@ import { authMiddleware } from './middleware/auth';
|
|||||||
import { csrfMiddleware } from './middleware/csrf';
|
import { csrfMiddleware } from './middleware/csrf';
|
||||||
import { globalLimiter, mutateLimiter, sensitiveLimiter, mnemonicRevealLimiter } from './middleware/rate-limit';
|
import { globalLimiter, mutateLimiter, sensitiveLimiter, mnemonicRevealLimiter } from './middleware/rate-limit';
|
||||||
import { errorHandler } from './middleware/error-handler';
|
import { errorHandler } from './middleware/error-handler';
|
||||||
import { generateCsrfToken } from './services/csrf.service';
|
|
||||||
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 jumperProxyRoutes from './routes/jumper-proxy.routes';
|
import jumperProxyRoutes from './routes/jumper-proxy.routes';
|
||||||
@@ -67,30 +66,6 @@ app.get('/api/health', async (_req, res) => {
|
|||||||
// ── Глобальный rate limit на /api/* — ДО docs чтобы не было unauthenticated DoS на swagger.json
|
// ── Глобальный rate limit на /api/* — ДО docs чтобы не было unauthenticated DoS на swagger.json
|
||||||
app.use('/api', globalLimiter);
|
app.use('/api', globalLimiter);
|
||||||
|
|
||||||
app.get('/api/csrf', (_req, res) => {
|
|
||||||
if (!env.vault.csrfPath) {
|
|
||||||
res.json({ success: true, data: { enabled: false, csrfToken: null } });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { token, maxAgeSec } = generateCsrfToken();
|
|
||||||
const crossSiteCookie = !corsIsWildcard && env.cors.allowCredentials;
|
|
||||||
res.cookie('csrf_token', token, {
|
|
||||||
httpOnly: false,
|
|
||||||
secure: crossSiteCookie,
|
|
||||||
sameSite: crossSiteCookie ? 'none' : 'lax',
|
|
||||||
maxAge: maxAgeSec * 1000,
|
|
||||||
path: '/',
|
|
||||||
});
|
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
|
||||||
res.setHeader('X-CSRF-Token', token);
|
|
||||||
res.json({ success: true, data: { enabled: true, csrfToken: token, maxAgeSec } });
|
|
||||||
} catch (err: any) {
|
|
||||||
res.status(503).json({ success: false, error: err.message || 'CSRF token unavailable' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// H1 — Swagger gated. В production требуется basic-auth ИЛИ NODE_ENV != production.
|
// H1 — Swagger gated. В production требуется basic-auth ИЛИ NODE_ENV != production.
|
||||||
// JSON endpoint ОБЯЗАТЕЛЬНО до app.use('/api/docs', ...) — иначе swagger-ui-express
|
// JSON endpoint ОБЯЗАТЕЛЬНО до app.use('/api/docs', ...) — иначе swagger-ui-express
|
||||||
// перехватывает все /api/docs/* и возвращает HTML вместо JSON.
|
// перехватывает все /api/docs/* и возвращает HTML вместо JSON.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import { timingSafeEqual } from 'crypto';
|
import { timingSafeEqual } from 'crypto';
|
||||||
import { verifyCsrfToken, isCsrfConfigured } from '../services/csrf.service';
|
import { verifyCsrfToken, isCsrfConfigured, getCsrfConfigSummary } from '../services/csrf.service';
|
||||||
import { env } from '../config/env';
|
import { env } from '../config/env';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
|
|
||||||
@@ -28,16 +28,6 @@ export function csrfMiddleware(req: Request, res: Response, next: NextFunction):
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bearer-auth (explicit Authorization header) — CSRF не нужен.
|
|
||||||
// Browser-based CSRF атаки требуют auto-sent cookie. Если auth идёт через explicit
|
|
||||||
// Authorization header, attacker не может его выставить cross-origin (CORS preflight блокирует).
|
|
||||||
// Это позволяет API-клиентам и фронту с Bearer-token работать без double-submit CSRF,
|
|
||||||
// даже если браузер параллельно прислал stale access_token cookie.
|
|
||||||
if (req.headers.authorization) {
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// CSRF включён, но секрет не загружен → fail-secure 503.
|
// CSRF включён, но секрет не загружен → fail-secure 503.
|
||||||
if (!isCsrfConfigured()) {
|
if (!isCsrfConfigured()) {
|
||||||
logger.error('CSRF check unavailable: secret not loaded — rejecting mutating request');
|
logger.error('CSRF check unavailable: secret not loaded — rejecting mutating request');
|
||||||
@@ -71,7 +61,9 @@ export function csrfMiddleware(req: Request, res: Response, next: NextFunction):
|
|||||||
result.actualSigLen !== undefined && result.expectedSigLen !== undefined
|
result.actualSigLen !== undefined && result.expectedSigLen !== undefined
|
||||||
? ` (sigLen=${result.actualSigLen} expectedLen=${result.expectedSigLen})`
|
? ` (sigLen=${result.actualSigLen} expectedLen=${result.expectedSigLen})`
|
||||||
: '';
|
: '';
|
||||||
logger.warn(`CSRF validation failed: ${result.reason}${sigDiag}`);
|
const cfg = getCsrfConfigSummary();
|
||||||
|
const fp = cfg ? ` secret_fp=${cfg.secretFp} salt="${cfg.salt}" digest=${cfg.digest}` : '';
|
||||||
|
logger.warn(`CSRF validation failed: ${result.reason}${sigDiag}${fp}`);
|
||||||
res.status(403).json({ success: false, error: 'Invalid CSRF token' });
|
res.status(403).json({ success: false, error: 'Invalid CSRF token' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,14 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { fetchVaultKV2 } from '../config/vault';
|
import { fetchVaultKV2 } from '../config/vault';
|
||||||
|
import { env } from '../config/env';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSRF token validation compatible with Python's `itsdangerous`
|
* CSRF validation compatible with Python `itsdangerous` URLSafeTimedSerializer (Flask-WTF).
|
||||||
* `URLSafeTimedSerializer` (which Flask-WTF uses).
|
* Token: <b64url_payload>.<b64url_timestamp>.<b64url_signature>
|
||||||
*
|
|
||||||
* Token format: <b64url_payload>.<b64url_timestamp>.<b64url_signature>
|
|
||||||
*
|
|
||||||
* Digests: sha1 (20-byte sig, legacy Flask-WTF), sha256 (32), sha512 (64, itsdangerous 2.x default).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const ITSDANGEROUS_EPOCH = 1293840000; // 2011-01-01 UTC in unix time
|
const ITSDANGEROUS_EPOCH = 1293840000;
|
||||||
|
|
||||||
/** Типичные salt у auth / Flask-WTF — только для verify fallback, не для generate. */
|
|
||||||
const LEGACY_VERIFY_SALTS = ['csrf-salt', 'csrf', 'csrf-token', 'itsdangerous.Signer'] as const;
|
|
||||||
|
|
||||||
export interface CsrfConfig {
|
export interface CsrfConfig {
|
||||||
secret: string;
|
secret: string;
|
||||||
@@ -33,8 +27,40 @@ export function isCsrfConfigured(): boolean {
|
|||||||
return current !== null;
|
return current !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function b64urlEncode(buf: Buffer): string {
|
/** Первые 8 hex SHA-256(secret) — для сравнения auth vs wallet без утечки ключа. */
|
||||||
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
export function csrfSecretFingerprint(secret: string): string {
|
||||||
|
return crypto.createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CsrfConfigSummary {
|
||||||
|
mount: string;
|
||||||
|
path: string;
|
||||||
|
salt: string;
|
||||||
|
digest: 'sha256' | 'sha512';
|
||||||
|
maxAgeSec: number;
|
||||||
|
secretFp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCsrfConfigSummary(): CsrfConfigSummary | null {
|
||||||
|
if (!current || !env.vault.csrfPath) return null;
|
||||||
|
return {
|
||||||
|
mount: env.vault.mount,
|
||||||
|
path: env.vault.csrfPath,
|
||||||
|
salt: current.salt,
|
||||||
|
digest: current.digest,
|
||||||
|
maxAgeSec: current.maxAgeSec,
|
||||||
|
secretFp: csrfSecretFingerprint(current.secret),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logCsrfConfigLoaded(): void {
|
||||||
|
const summary = getCsrfConfigSummary();
|
||||||
|
if (!summary) return;
|
||||||
|
logger.info(
|
||||||
|
`CSRF config loaded: mount=${summary.mount} path=${summary.path} ` +
|
||||||
|
`salt="${summary.salt}" digest=${summary.digest} maxAgeSec=${summary.maxAgeSec} ` +
|
||||||
|
`secret_fp=${summary.secretFp}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchCsrfConfig(
|
export async function fetchCsrfConfig(
|
||||||
@@ -54,19 +80,24 @@ export async function fetchCsrfConfig(
|
|||||||
throw new Error('CSRF secret invalid: must be string >= 32 chars');
|
throw new Error('CSRF secret invalid: must be string >= 32 chars');
|
||||||
}
|
}
|
||||||
|
|
||||||
const salt = secrets.salt || 'itsdangerous.Signer';
|
const salt = secrets.salt;
|
||||||
if (typeof salt !== 'string' || salt.length < 8) {
|
if (!salt || typeof salt !== 'string' || salt.length < 4) {
|
||||||
throw new Error('CSRF salt invalid: must be string >= 8 chars');
|
throw new Error(
|
||||||
|
'CSRF salt required in Vault KV (field "salt", min 4 chars). ' +
|
||||||
|
'Example: vault kv patch -mount=<mount> <csrf_path> salt=csrf-salt digest=sha256',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let digest: 'sha256' | 'sha512' = 'sha256';
|
let digest: 'sha256' | 'sha512' = 'sha256';
|
||||||
if (secrets.digest === 'sha256' || secrets.digest === 'sha512') {
|
if (secrets.digest === 'sha256' || secrets.digest === 'sha512') {
|
||||||
digest = secrets.digest;
|
digest = secrets.digest;
|
||||||
|
} else if (secrets.digest) {
|
||||||
|
throw new Error(`CSRF digest invalid: ${secrets.digest} (allowed: sha256, sha512)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let maxAgeSec = 60 * 60 * 24 * 7;
|
let maxAgeSec = 60 * 60 * 24 * 7;
|
||||||
if (secrets.max_age_sec) {
|
if (secrets.max_age_sec) {
|
||||||
const n = parseInt(secrets.max_age_sec);
|
const n = parseInt(String(secrets.max_age_sec), 10);
|
||||||
if (!Number.isNaN(n) && n > 0) maxAgeSec = n;
|
if (!Number.isNaN(n) && n > 0) maxAgeSec = n;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,71 +121,15 @@ function decodeTimestamp(encoded: string): number {
|
|||||||
return ts + ITSDANGEROUS_EPOCH;
|
return ts + ITSDANGEROUS_EPOCH;
|
||||||
}
|
}
|
||||||
|
|
||||||
function encodeTimestamp(unixSeconds: number): string {
|
|
||||||
let ts = unixSeconds - ITSDANGEROUS_EPOCH;
|
|
||||||
const bytes: number[] = [];
|
|
||||||
do {
|
|
||||||
bytes.unshift(ts & 0xff);
|
|
||||||
ts = Math.floor(ts / 256);
|
|
||||||
} while (ts > 0);
|
|
||||||
return b64urlEncode(Buffer.from(bytes));
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CsrfVerifyResult {
|
export interface CsrfVerifyResult {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
actualSigLen?: number;
|
actualSigLen?: number;
|
||||||
expectedSigLen?: number;
|
expectedSigLen?: number;
|
||||||
fallbackDigest?: CsrfDigest;
|
|
||||||
fallbackSalt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CsrfConfigSummary {
|
|
||||||
salt: string;
|
|
||||||
digest: 'sha256' | 'sha512';
|
|
||||||
maxAgeSec: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type CsrfDigest = 'sha1' | 'sha256' | 'sha512';
|
type CsrfDigest = 'sha1' | 'sha256' | 'sha512';
|
||||||
|
|
||||||
const DIGEST_BY_SIG_LEN: Record<number, CsrfDigest> = {
|
|
||||||
20: 'sha1',
|
|
||||||
32: 'sha256',
|
|
||||||
64: 'sha512',
|
|
||||||
};
|
|
||||||
|
|
||||||
const ALL_DIGESTS: CsrfDigest[] = ['sha1', 'sha256', 'sha512'];
|
|
||||||
|
|
||||||
export function getCsrfConfigSummary(): CsrfConfigSummary | null {
|
|
||||||
if (!current) return null;
|
|
||||||
return { salt: current.salt, digest: current.digest, maxAgeSec: current.maxAgeSec };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logCsrfConfigLoaded(): void {
|
|
||||||
const summary = getCsrfConfigSummary();
|
|
||||||
if (!summary) return;
|
|
||||||
logger.info(
|
|
||||||
`CSRF config loaded: salt="${summary.salt}" digest=${summary.digest} maxAgeSec=${summary.maxAgeSec}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateCsrfToken(): { token: string; maxAgeSec: number } {
|
|
||||||
if (!current) {
|
|
||||||
throw new Error('CSRF secret not loaded');
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = b64urlEncode(crypto.randomBytes(32));
|
|
||||||
const timestamp = encodeTimestamp(Math.floor(Date.now() / 1000));
|
|
||||||
const payloadTs = `${payload}.${timestamp}`;
|
|
||||||
const derived = deriveKey(current.secret, current.salt, current.digest);
|
|
||||||
const signature = b64urlEncode(crypto.createHmac(current.digest, derived).update(payloadTs).digest());
|
|
||||||
|
|
||||||
return {
|
|
||||||
token: `${payloadTs}.${signature}`,
|
|
||||||
maxAgeSec: current.maxAgeSec,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function verifyCsrfTokenWithParams(
|
function verifyCsrfTokenWithParams(
|
||||||
cfg: CsrfConfig,
|
cfg: CsrfConfig,
|
||||||
salt: string,
|
salt: string,
|
||||||
@@ -208,80 +183,41 @@ function verifyCsrfTokenWithParams(
|
|||||||
return { valid: true };
|
return { valid: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Минимальная длина salt при verify-fallback (Flask-WTF default `csrf` = 4). */
|
function allowSha1VerifyBridge(): boolean {
|
||||||
const MIN_VERIFY_SALT_LEN = 1;
|
return process.env.CSRF_ALLOW_SHA1_VERIFY === 'true';
|
||||||
|
|
||||||
function saltsToTry(vaultSalt: string): string[] {
|
|
||||||
const out: string[] = [];
|
|
||||||
const add = (s: string, minLen = MIN_VERIFY_SALT_LEN) => {
|
|
||||||
if (s && s.length >= minLen && !out.includes(s)) out.push(s);
|
|
||||||
};
|
|
||||||
add(vaultSalt, 8);
|
|
||||||
for (const s of LEGACY_VERIFY_SALTS) add(s);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function digestsToTry(primary: CsrfVerifyResult, vaultDigest: CsrfDigest): CsrfDigest[] {
|
|
||||||
const order: CsrfDigest[] = [];
|
|
||||||
const add = (d: CsrfDigest) => {
|
|
||||||
if (!order.includes(d)) order.push(d);
|
|
||||||
};
|
|
||||||
add(vaultDigest);
|
|
||||||
if (primary.actualSigLen !== undefined) {
|
|
||||||
const inferred = DIGEST_BY_SIG_LEN[primary.actualSigLen];
|
|
||||||
if (inferred) add(inferred);
|
|
||||||
}
|
|
||||||
for (const d of ALL_DIGESTS) add(d);
|
|
||||||
return order;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRetryableVerifyFailure(reason?: string): boolean {
|
|
||||||
return reason === 'Signature length mismatch' || reason === 'Signature mismatch';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify CSRF: Vault salt+digest first, then legacy digest/salt matrix (auth Flask-WTF).
|
* Strict verify: Vault salt + digest only.
|
||||||
|
* Optional bridge: CSRF_ALLOW_SHA1_VERIFY=true tries sha1 + same Vault salt (legacy auth, one release).
|
||||||
*/
|
*/
|
||||||
export function verifyCsrfToken(token: string): CsrfVerifyResult {
|
export function verifyCsrfToken(token: string): CsrfVerifyResult {
|
||||||
if (!current) return { valid: false, reason: 'CSRF secret not loaded' };
|
if (!current) return { valid: false, reason: 'CSRF secret not loaded' };
|
||||||
|
|
||||||
const vaultSalt = current.salt;
|
const primary = verifyCsrfTokenWithParams(current, current.salt, current.digest, token);
|
||||||
const vaultDigest = current.digest as CsrfDigest;
|
|
||||||
const salts = saltsToTry(vaultSalt);
|
|
||||||
|
|
||||||
const primary = verifyCsrfTokenWithParams(current, vaultSalt, vaultDigest, token);
|
|
||||||
if (primary.valid) return primary;
|
if (primary.valid) return primary;
|
||||||
|
|
||||||
if (!isRetryableVerifyFailure(primary.reason)) {
|
if (
|
||||||
return primary;
|
allowSha1VerifyBridge() &&
|
||||||
}
|
primary.reason === 'Signature length mismatch' &&
|
||||||
|
primary.actualSigLen === 20 &&
|
||||||
const digests = digestsToTry(primary, vaultDigest);
|
(current.digest === 'sha256' || current.digest === 'sha512')
|
||||||
let lastMismatch: CsrfVerifyResult = primary;
|
) {
|
||||||
|
const legacy = verifyCsrfTokenWithParams(current, current.salt, 'sha1', token);
|
||||||
for (const salt of salts) {
|
if (legacy.valid) {
|
||||||
for (const digest of digests) {
|
logger.warn(
|
||||||
if (salt === vaultSalt && digest === vaultDigest) continue;
|
'CSRF verified via CSRF_ALLOW_SHA1_VERIFY sha1 bridge — migrate auth to Vault digest=sha256',
|
||||||
|
);
|
||||||
const attempt = verifyCsrfTokenWithParams(current, salt, digest, token);
|
return legacy;
|
||||||
if (attempt.valid) {
|
}
|
||||||
logger.warn(
|
if (legacy.reason === 'Signature mismatch') {
|
||||||
`CSRF verified with fallback digest=${digest} salt="${salt}" ` +
|
return { valid: false, reason: 'Signature mismatch (Vault secret/salt does not match token)' };
|
||||||
`(Vault digest=${vaultDigest} salt="${vaultSalt}"). ` +
|
|
||||||
'Align auth-service with Vault `digest` and `salt` fields.',
|
|
||||||
);
|
|
||||||
return { valid: true, fallbackDigest: digest, fallbackSalt: salt };
|
|
||||||
}
|
|
||||||
if (attempt.reason === 'Signature mismatch' || attempt.reason === 'Signature length mismatch') {
|
|
||||||
lastMismatch = attempt;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
if (primary.reason === 'Signature mismatch') {
|
||||||
valid: false,
|
return { valid: false, reason: 'Signature mismatch (Vault secret/salt does not match token)' };
|
||||||
reason: 'Signature mismatch (all digest/salt fallbacks failed)',
|
}
|
||||||
actualSigLen: primary.actualSigLen ?? lastMismatch.actualSigLen,
|
|
||||||
expectedSigLen: primary.expectedSigLen ?? lastMismatch.expectedSigLen,
|
return primary;
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user