diff --git a/src/__tests__/admin-response-shape.test.ts b/src/__tests__/admin-response-shape.test.ts index 0ada91a4..1fdf1d96 100644 --- a/src/__tests__/admin-response-shape.test.ts +++ b/src/__tests__/admin-response-shape.test.ts @@ -36,7 +36,7 @@ function buildApp(): Express { return app; } -const authHeader = { Authorization: "Bearer test-key" }; +const authHeader = { Authorization: "Bearer test-key", "x-request-timestamp": Date.now().toString() }; describe("admin /update-scores response shape", () => { let app: Express; diff --git a/src/__tests__/admin-validation.test.ts b/src/__tests__/admin-validation.test.ts index 0b4eabd6..7a568719 100644 --- a/src/__tests__/admin-validation.test.ts +++ b/src/__tests__/admin-validation.test.ts @@ -30,7 +30,7 @@ function buildApp(): Express { } const ADMIN_API_KEY = "test-key"; -const authHeader = { Authorization: `Bearer ${ADMIN_API_KEY}` }; +const authHeader = { Authorization: `Bearer ${ADMIN_API_KEY}`, "x-request-timestamp": Date.now().toString() }; describe("admin /update-scores input validation", () => { let app: Express; diff --git a/src/__tests__/admin.test.ts b/src/__tests__/admin.test.ts index fb35037d..bec4f960 100644 --- a/src/__tests__/admin.test.ts +++ b/src/__tests__/admin.test.ts @@ -36,7 +36,8 @@ function buildApp(): Express { return app; } -const AUTH_HEADER = { Authorization: "Bearer test-key" }; +const AUTH_HEADER = { Authorization: "Bearer test-key", "x-request-timestamp": Date.now().toString() }; + describe("admin routes", () => { let app: Express; @@ -148,6 +149,7 @@ describe("admin routes", () => { const res = await request(app) .post("/api/admin/update-scores") .set("Authorization", "Bearer test-key ") + .set("x-request-timestamp", Date.now().toString()) .send({}); expect(res.status).toBe(200); }); diff --git a/src/__tests__/routes.test.ts b/src/__tests__/routes.test.ts index 4181e29f..cc3da8ab 100644 --- a/src/__tests__/routes.test.ts +++ b/src/__tests__/routes.test.ts @@ -22,7 +22,7 @@ jest.mock("../config", () => { }); const ADMIN_API_KEY = "test-key"; -const authHeader = { Authorization: `Bearer ${ADMIN_API_KEY}` }; +const authHeader = { Authorization: `Bearer ${ADMIN_API_KEY}`, "x-request-timestamp": Date.now().toString()}; function buildApp(): Express { const app = express(); diff --git a/src/__tests__/security.test.ts b/src/__tests__/security.test.ts index 3d3988bb..1cea6b50 100644 --- a/src/__tests__/security.test.ts +++ b/src/__tests__/security.test.ts @@ -54,6 +54,7 @@ describe("Security - injection attacks", () => { const res = await request(app) .post("/api/admin/update-scores") .set("Authorization", "Bearer test-key") + .set("x-request-timestamp", Date.now().toString()) .send(JSON.parse('{"__proto__": {"project_ids": [999]}}')) .expect(200); @@ -66,6 +67,7 @@ describe("Security - injection attacks", () => { const res = await request(app) .post("/api/admin/update-scores") .set("Authorization", "Bearer test-key") + .set("x-request-timestamp", Date.now().toString()) .send({ constructor: { prototype: { project_ids: [999] } } }) .expect(200); @@ -73,4 +75,4 @@ describe("Security - injection attacks", () => { expect(registry.getTotalProjects).toHaveBeenCalled(); }); }); -}); \ No newline at end of file +}); diff --git a/src/config.ts b/src/config.ts index c8f19601..30539bf8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -70,6 +70,98 @@ function networkEnv(name: string, fallback: StellarNetwork): StellarNetwork { return isStellarNetwork(raw) ? raw : fallback; } +export const config = { + /** Stellar / Soroban */ + STELLAR_NETWORK: networkEnv("STELLAR_NETWORK", "testnet"), + ADMIN_SECRET_KEY: process.env.ADMIN_SECRET_KEY || "", + PROJECT_REGISTRY_CONTRACT_ID: process.env.PROJECT_REGISTRY_CONTRACT_ID || "", + RPC_URL: optionalEnv("RPC_URL", "https://soroban-testnet.stellar.org"), + + /** HTTP server */ + PORT: numEnv("PORT", 3001), + FRONTEND_URL: optionalEnv("FRONTEND_URL", "http://localhost:3000"), + ADMIN_API_KEY: process.env.ADMIN_API_KEY || "", + ADMIN_REQUEST_MAX_AGE_MS: numEnv("ADMIN_REQUEST_MAX_AGE_MS", 300000), + INITIAL_ADMIN_USER_ID: process.env.INITIAL_ADMIN_USER_ID || "", + WS_AUTH_TOKEN: process.env.WS_AUTH_TOKEN || "", + + /** Database connection */ + DB_HOST: optionalEnv("DB_HOST", "localhost"), + DB_PORT: numEnv("DB_PORT", 5432), + DB_NAME: optionalEnv("DB_NAME", ""), + DB_USER: optionalEnv("DB_USER", "postgres"), + DB_PASSWORD: optionalEnv("DB_PASSWORD", ""), + + /** Connection pool */ + DB_POOL_MIN: numEnv("DB_POOL_MIN", 2), + DB_POOL_MAX: numEnv("DB_POOL_MAX", 10), + DB_POOL_ACQUIRE_TIMEOUT_MS: numEnv("DB_POOL_ACQUIRE_TIMEOUT_MS", 5000), + DB_POOL_HEALTH_CHECK_INTERVAL_MS: numEnv("DB_POOL_HEALTH_CHECK_INTERVAL_MS", 30000), + + /** Circuit breaker */ + RPC_BREAKER_FAILURE_THRESHOLD: numEnv( + "CIRCUIT_BREAKER_THRESHOLD", + numEnv("RPC_BREAKER_FAILURE_THRESHOLD", 5), + ), + RPC_BREAKER_RECOVERY_TIMEOUT_MS: numEnv( + "CIRCUIT_BREAKER_COOLDOWN_MS", + numEnv("RPC_BREAKER_RECOVERY_TIMEOUT_MS", 30000), + ), + + /** Transaction retries */ + TX_MAX_RETRIES: numEnv("TX_MAX_RETRIES", 4), + TX_RETRY_BASE_DELAY_MS: numEnv("TX_RETRY_BASE_DELAY_MS", 200), + TX_RETRY_MAX_DELAY_MS: numEnv("TX_RETRY_MAX_DELAY_MS", 10000), + + /** Stellar transaction polling */ + POLL_INTERVAL_MS: numEnv("POLL_INTERVAL_MS", 1500), + POLL_MAX_ATTEMPTS: numEnv("POLL_MAX_ATTEMPTS", 20), + + /** Stellar transaction timeout (seconds) */ + TX_TIMEOUT_SECONDS: numEnv("TX_TIMEOUT_SECONDS", 30), + + /** IoT max power output (kW) */ + MAX_POWER_KW: numEnv("MAX_POWER_KW", 1000), + + /** Idempotency */ + IDEMPOTENCY_TTL_MS: numEnv("IDEMPOTENCY_TTL_MS", 3_600_000), + + /** Cron */ + CRON_TIMEZONE: optionalEnv("CRON_TIMEZONE", "UTC"), + CRON_FAILURE_THRESHOLD: floatEnv("CRON_FAILURE_THRESHOLD", 0.5), + + /** Graceful shutdown */ + SHUTDOWN_TIMEOUT_MS: numEnv("SHUTDOWN_TIMEOUT_MS", 30000), + + /** Logging */ + LOG_LEVEL: optionalEnv("LOG_LEVEL", ""), + NODE_ENV: optionalEnv("NODE_ENV", "development"), + + /** Rate limiting */ + RATE_LIMIT_WINDOW_MS: numEnv("RATE_LIMIT_WINDOW_MS", 60000), + RATE_LIMIT_MAX: numEnv("RATE_LIMIT_MAX", 100), + RATE_LIMIT_ADMIN_WINDOW_MS: numEnv("RATE_LIMIT_ADMIN_WINDOW_MS", 60000), + RATE_LIMIT_ADMIN_MAX: numEnv("RATE_LIMIT_ADMIN_MAX", 20), + + /** IP Whitelist */ + ADMIN_IP_WHITELIST: optionalEnv("ADMIN_IP_WHITELIST", ""), + ADMIN_IP_WHITELIST_BYPASS_PRIVATE: optionalEnv("ADMIN_IP_WHITELIST_BYPASS_PRIVATE", "true"), + + /** Request Signing */ + REQUEST_SIGNING_SECRET: optionalEnv("REQUEST_SIGNING_SECRET", ""), + + /** APM */ + APM_PROVIDER: optionalEnv("APM_PROVIDER", "none"), + + /** CSRF */ + CORS_ORIGINS: optionalEnv("CORS_ORIGINS", ""), + + /** Body size limit */ + BODY_SIZE_LIMIT: optionalEnv("BODY_SIZE_LIMIT", "100kb"), + + /** Secrets Management */ + SECRETS_PROVIDER: optionalEnv("SECRETS_PROVIDER", "env"), +} as const; function buildConfig() { return { /** Stellar / Soroban */ diff --git a/src/routes/admin.ts b/src/routes/admin.ts index ef88ab3f..c6e0d867 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -65,6 +65,30 @@ router.use((req: Request, res: Response, next: NextFunction) => { next(); }); +// Timestamp expiration validation (Issue #545) +router.use((req: Request, res: Response, next: NextFunction) => { + const timestampHeader = req.header("x-request-timestamp"); + if (!timestampHeader) { + logger.warn("[admin] Missing X-Request-Timestamp header"); + return res.status(401).json(errorBody("unauthorized", "Missing X-Request-Timestamp header")); + } + + const clientTime = parseInt(timestampHeader, 10); + if (isNaN(clientTime)) { + logger.warn("[admin] Invalid X-Request-Timestamp header format"); + return res.status(401).json(errorBody("unauthorized", "Invalid timestamp format")); + } + + const ageMs = Math.abs(Date.now() - clientTime); + if (ageMs > config.ADMIN_REQUEST_MAX_AGE_MS) { + logger.warn(`[admin] Request expired. Age: ${ageMs}ms, Max allowed: ${config.ADMIN_REQUEST_MAX_AGE_MS}ms`); + return res.status(401).json(errorBody("unauthorized", "Request expired")); + } + + next(); +}); + + /** A per-project score update that made it onto the ledger (or was deferred). */ type ScoreUpdateResult = { project_id: number;