initjirefr

This commit is contained in:
ZOMBIIIIIII
2026-05-28 23:29:18 +03:00
parent 4c00c6ca1b
commit 31aba0b681
10 changed files with 393 additions and 104 deletions

37
apps/api/Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
WORKDIR /app
# ── deps: install all node_modules ───────────────────────────────────────────
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json apps/api/
RUN pnpm install --frozen-lockfile --prod=false
# ── build: compile TypeScript ────────────────────────────────────────────────
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
COPY . .
RUN cd apps/api && pnpm build
# ── prod-deps: production-only dependencies ─────────────────────────────────
FROM base AS prod-deps
RUN apk add --no-cache python3 make g++
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json apps/api/
RUN pnpm install --frozen-lockfile --prod \
&& BIGINT_DIR="$(find /app -path '*/node_modules/bigint-buffer' -type d 2>/dev/null | head -1)" \
&& if [ -n "$BIGINT_DIR" ]; then (cd "$BIGINT_DIR" && npm run rebuild); fi
# ── runtime: minimal image ───────────────────────────────────────────────────
FROM node:20-alpine AS runtime
WORKDIR /app/apps/api
COPY --from=prod-deps /app/node_modules /app/node_modules
COPY --from=prod-deps /app/apps/api/node_modules ./node_modules
COPY --from=build /app/apps/api/dist ./dist
COPY --from=build /app/apps/api/swagger.json ./swagger.json
COPY --from=build /app/apps/api/package.json ./package.json
EXPOSE 3001
CMD ["node", "dist/index.js"]

View File

@@ -67,7 +67,11 @@ export function csrfMiddleware(req: Request, res: Response, next: NextFunction):
// HMAC verify только после совпадения двух source'ов.
const result = verifyCsrfToken(cookieToken);
if (!result.valid) {
logger.warn(`CSRF validation failed: ${result.reason}`);
const sigDiag =
result.actualSigLen !== undefined && result.expectedSigLen !== undefined
? ` (sigLen=${result.actualSigLen} expectedLen=${result.expectedSigLen})`
: '';
logger.warn(`CSRF validation failed: ${result.reason}${sigDiag}`);
res.status(403).json({ success: false, error: 'Invalid CSRF token' });
return;
}

View File

@@ -1,5 +1,6 @@
import crypto from 'crypto';
import { fetchVaultKV2 } from '../config/vault';
import { logger } from '../lib/logger';
/**
* CSRF token validation compatible with Python's `itsdangerous`
@@ -66,7 +67,9 @@ export async function fetchCsrfConfig(
}
// sha1 deprecated — accept только sha256/sha512.
let digest: 'sha256' | 'sha512' = 'sha512';
// Default sha256: совпадает с deploy vault-init и типичным Flask config при явном digest_method.
// itsdangerous 2.x без digest → sha512; wallet API при несовпадении пробует fallback в verifyCsrfToken.
let digest: 'sha256' | 'sha512' = 'sha256';
if (secrets.digest === 'sha256' || secrets.digest === 'sha512') {
digest = secrets.digest;
}
@@ -112,6 +115,27 @@ function encodeTimestamp(unixSeconds: number): string {
export interface CsrfVerifyResult {
valid: boolean;
reason?: string;
actualSigLen?: number;
expectedSigLen?: number;
}
export interface CsrfConfigSummary {
salt: string;
digest: 'sha256' | 'sha512';
maxAgeSec: number;
}
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 } {
@@ -131,8 +155,7 @@ export function generateCsrfToken(): { token: string; maxAgeSec: number } {
};
}
export function verifyCsrfToken(token: string): CsrfVerifyResult {
if (!current) return { valid: false, reason: 'CSRF secret not loaded' };
function verifyCsrfTokenWithConfig(cfg: CsrfConfig, token: string): CsrfVerifyResult {
if (!token || typeof token !== 'string') return { valid: false, reason: 'Empty token' };
const lastDot = token.lastIndexOf('.');
@@ -146,8 +169,8 @@ export function verifyCsrfToken(token: string): CsrfVerifyResult {
const tsStr = payloadTs.slice(prevDot + 1);
const derived = deriveKey(current.secret, current.salt, current.digest);
const expectedSig = crypto.createHmac(current.digest, derived).update(payloadTs).digest();
const derived = deriveKey(cfg.secret, cfg.salt, cfg.digest);
const expectedSig = crypto.createHmac(cfg.digest, derived).update(payloadTs).digest();
let actualSig: Buffer;
try {
@@ -157,7 +180,12 @@ export function verifyCsrfToken(token: string): CsrfVerifyResult {
}
if (expectedSig.length !== actualSig.length) {
return { valid: false, reason: 'Signature length mismatch' };
return {
valid: false,
reason: 'Signature length mismatch',
expectedSigLen: expectedSig.length,
actualSigLen: actualSig.length,
};
}
if (!crypto.timingSafeEqual(expectedSig, actualSig)) {
return { valid: false, reason: 'Signature mismatch' };
@@ -167,10 +195,37 @@ export function verifyCsrfToken(token: string): CsrfVerifyResult {
const issuedAt = decodeTimestamp(tsStr);
const now = Math.floor(Date.now() / 1000);
if (issuedAt > now + 60) return { valid: false, reason: 'Token from the future' };
if (now - issuedAt > current.maxAgeSec) return { valid: false, reason: 'Token expired' };
if (now - issuedAt > cfg.maxAgeSec) return { valid: false, reason: 'Token expired' };
} catch {
return { valid: false, reason: 'Invalid timestamp' };
}
return { valid: true };
}
/**
* Verify CSRF token. If Vault digest differs from auth-service (sha256 vs sha512),
* retry once with the alternate digest — типичный случай Flask-WTF / itsdangerous 2.x.
*/
export function verifyCsrfToken(token: string): CsrfVerifyResult {
if (!current) return { valid: false, reason: 'CSRF secret not loaded' };
const primary = verifyCsrfTokenWithConfig(current, token);
if (primary.valid) return primary;
if (primary.reason !== 'Signature length mismatch') {
return primary;
}
const altDigest: 'sha256' | 'sha512' = current.digest === 'sha256' ? 'sha512' : 'sha256';
const fallback = verifyCsrfTokenWithConfig({ ...current, digest: altDigest }, token);
if (fallback.valid) {
logger.warn(
`CSRF verified with fallback digest ${altDigest} (Vault digest=${current.digest}). ` +
'Align auth-service URLSafeTimedSerializer digest_method with Vault `digest` field.',
);
return { valid: true };
}
return primary;
}

View File

@@ -1,7 +1,7 @@
import { env } from '../config/env';
import { vaultAppRoleLogin } from '../config/vault';
import { fetchJwtKeysFromVault, swapKeyMap, getKeyMapSize } from './jwt.service';
import { fetchCsrfConfig, swapCsrfConfig } from './csrf.service';
import { fetchCsrfConfig, swapCsrfConfig, logCsrfConfigLoaded } from './csrf.service';
import { fetchMasterKey, swapMasterKey, masterKeyMatches, isCryptoReady } from './crypto.service';
import { logger } from '../lib/logger';
@@ -83,6 +83,7 @@ async function doRefresh(): Promise<RefreshResult> {
swapKeyMap(jwtResult.value);
if (csrfResult.status === 'fulfilled' && csrfResult.value) {
swapCsrfConfig(csrfResult.value);
logCsrfConfigLoaded();
}
if (cryptoResult.status === 'fulfilled' && cryptoResult.value) {
if (!isCryptoReady()) {