diff --git a/.env.example b/.env.example index 48f692f..1529261 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,10 @@ RATE_LIMIT_MAX=100 # Admin endpoints: stricter limits (default window mirrors RATE_LIMIT_WINDOW_MS) RATE_LIMIT_ADMIN_WINDOW_MS=60000 RATE_LIMIT_ADMIN_MAX=20 + +# --- IP Whitelist for admin endpoints (optional) --- +# Comma-separated list of allowed IPs or CIDR ranges +# If empty or unset, IP whitelist is disabled (all IPs allowed) +ADMIN_IP_WHITELIST= +# Set to "false" to NOT bypass private/internal networks (default: true bypasses them) +ADMIN_IP_WHITELIST_BYPASS_PRIVATE=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index efd416c..a1b66a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,3 +27,45 @@ jobs: - name: Test run: bun run test + + dependency-audit: + name: Dependency Vulnerability Scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Install bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run npm audit (high and critical) + run: npm audit --audit-level=high + continue-on-error: true + id: audit + + - name: Run npm audit (all severities for reporting) + run: | + echo "## Dependency Audit Report" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + npm audit --json > audit-report.json || true + if [ -s audit-report.json ]; then + CRITICAL=$(cat audit-report.json | node -e "const d=require('fs').readFileSync(0,'utf8');const j=JSON.parse(d);console.log(j.metadata?.vulnerabilities?.critical || 0)") + HIGH=$(cat audit-report.json | node -e "const d=require('fs').readFileSync(0,'utf8');const j=JSON.parse(d);console.log(j.metadata?.vulnerabilities?.high || 0)") + MODERATE=$(cat audit-report.json | node -e "const d=require('fs').readFileSync(0,'utf8');const j=JSON.parse(d);console.log(j.metadata?.vulnerabilities?.moderate || 0)") + LOW=$(cat audit-report.json | node -e "const d=require('fs').readFileSync(0,'utf8');const j=JSON.parse(d);console.log(j.metadata?.vulnerabilities?.low || 0)") + echo "| Severity | Count |" >> $GITHUB_STEP_SUMMARY + echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Critical | $CRITICAL |" >> $GITHUB_STEP_SUMMARY + echo "| High | $HIGH |" >> $GITHUB_STEP_SUMMARY + echo "| Moderate | $MODERATE |" >> $GITHUB_STEP_SUMMARY + echo "| Low | $LOW |" >> $GITHUB_STEP_SUMMARY + else + echo "No vulnerabilities found." >> $GITHUB_STEP_SUMMARY + fi + if: always() + + - name: Fail on critical vulnerabilities + if: steps.audit.outcome == 'failure' + run: exit 1 diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml new file mode 100644 index 0000000..bddc322 --- /dev/null +++ b/.github/workflows/security-audit.yml @@ -0,0 +1,158 @@ +name: Security Audit + +on: + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + security-events: write + +jobs: + dependency-audit: + name: Dependency Vulnerability Scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Install bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run npm audit + run: npm audit --json > npm-audit-report.json || true + + - name: Parse audit results + run: | + echo "## Dependency Audit Report" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Date:** $(date -u +'%Y-%m-%d')" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ -s npm-audit-report.json ]; then + node -e ' + const fs = require("fs"); + const report = JSON.parse(fs.readFileSync("npm-audit-report.json", "utf8")); + const v = report.metadata?.vulnerabilities || {}; + console.log("| Severity | Count |"); + ' | tee -a $GITHUB_STEP_SUMMARY + echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY + node -e ' + const fs = require("fs"); + const report = JSON.parse(fs.readFileSync("npm-audit-report.json", "utf8")); + const v = report.metadata?.vulnerabilities || {}; + ["critical","high","moderate","low","info"].forEach(sev => { + console.log(`| ${sev.charAt(0).toUpperCase()+sev.slice(1)} | ${v[sev] || 0} |`); + }); + ' >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + CRITICAL=$(node -e 'const r=JSON.parse(require("fs").readFileSync("npm-audit-report.json","utf8"));console.log(r.metadata?.vulnerabilities?.critical||0)') + if [ "$CRITICAL" -gt 0 ]; then + echo "::error::Found $CRITICAL critical vulnerabilities in dependencies" + fi + else + echo "No dependencies scanned or no vulnerabilities found." >> $GITHUB_STEP_SUMMARY + fi + + - name: Upload audit report + uses: actions/upload-artifact@v4 + with: + name: security-audit-report + path: npm-audit-report.json + retention-days: 90 + + code-vulnerability-scan: + name: Code Vulnerability Scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Run Trivy vulnerability scanner (filesystem) + uses: aquasecurity/trivy-action@master + with: + scan-type: fs + scan-ref: . + severity: CRITICAL,HIGH + format: table + exit-code: 1 + + secret-detection: + name: Secret Detection + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Run Gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + license-compliance: + name: License Compliance + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Install bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Check license compliance + run: | + echo "## License Compliance Report" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Date:** $(date -u +'%Y-%m-%d')" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + npx license-checker --json --out licenses.json 2>/dev/null || true + if [ -s licenses.json ]; then + echo "| Package | License |" >> $GITHUB_STEP_SUMMARY + echo "|---------|---------|" >> $GITHUB_STEP_SUMMARY + node -e ' + const fs = require("fs"); + const licenses = JSON.parse(fs.readFileSync("licenses.json", "utf8")); + const entries = Object.entries(licenses).slice(0, 50); + entries.forEach(([pkg, info]) => { + console.log(`| ${pkg} | ${info.licenses || "UNKNOWN"} |`); + }); + if (Object.keys(licenses).length > 50) { + console.log(`| ... | ... (${Object.keys(licenses).length - 50} more) |`); + } + ' >> $GITHUB_STEP_SUMMARY + else + echo "No license data available." >> $GITHUB_STEP_SUMMARY + fi + + - name: Upload license report + uses: actions/upload-artifact@v4 + with: + name: license-report + path: licenses.json + retention-days: 90 + + security-headers-check: + name: Security Headers Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Verify security headers configuration + run: | + echo "## Security Headers Audit" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Checking middleware/securityHeaders.ts..." >> $GITHUB_STEP_SUMMARY + if grep -q "helmet" src/middleware/securityHeaders.ts 2>/dev/null; then + echo "- [x] Helmet security headers configured" >> $GITHUB_STEP_SUMMARY + else + echo "- [ ] Helmet security headers NOT configured" >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + echo "Verifying CSP, HSTS, X-Frame-Options, X-Content-Type-Options..." >> $GITHUB_STEP_SUMMARY + grep -E "contentSecurityPolicy|hsts|frameguard|noSniff" src/middleware/securityHeaders.ts | while read -r line; do + echo "- \`$line\`" >> $GITHUB_STEP_SUMMARY + done diff --git a/src/__tests__/apiKeysRotation.test.ts b/src/__tests__/apiKeysRotation.test.ts new file mode 100644 index 0000000..57619f7 --- /dev/null +++ b/src/__tests__/apiKeysRotation.test.ts @@ -0,0 +1,178 @@ +import express from "express"; +import request from "supertest"; +import { + generateApiKey, + rotateApiKey, + validateApiKey, + clearApiKeys, + checkScheduledRotations, + getRotationStatus, + onRotation, +} from "../lib/apiKeys"; +import apiKeysRouter from "../routes/apiKeys"; + +describe("API Key Rotation", () => { + let originalAdminKey: string | undefined; + + beforeAll(() => { + originalAdminKey = process.env.ADMIN_API_KEY; + process.env.ADMIN_API_KEY = "admin-secret-key"; + }); + + afterAll(() => { + process.env.ADMIN_API_KEY = originalAdminKey; + }); + + beforeEach(() => { + clearApiKeys(); + }); + + describe("Scheduled Rotation", () => { + it("should set next_rotation_at when rotation_interval_days is provided", () => { + const key = generateApiKey("Consumer", 100, 30); + expect(key.rotation_interval_days).toBe(30); + expect(key.next_rotation_at).toBeDefined(); + expect(key.next_rotation_at).toBeGreaterThan(Date.now()); + }); + + it("should not set rotation schedule when rotation_interval_days is not provided", () => { + const key = generateApiKey("Consumer", 100); + expect(key.rotation_interval_days).toBeUndefined(); + expect(key.next_rotation_at).toBeUndefined(); + }); + + it("should rotate keys that have passed their rotation time", () => { + const key = generateApiKey("Consumer", 100, 1); + const oldKey = key.key; + + // Simulate time passing by setting next_rotation_at to past + key.next_rotation_at = Date.now() - 1000; + + const rotated = checkScheduledRotations(); + expect(rotated.length).toBe(1); + expect(rotated[0].key).not.toBe(oldKey); + }); + + it("should not rotate keys that are still within rotation interval", () => { + generateApiKey("Consumer", 100, 30); + + const rotated = checkScheduledRotations(); + expect(rotated.length).toBe(0); + }); + + it("should return correct rotation status", () => { + generateApiKey("Consumer1", 100, 30); + generateApiKey("Consumer2", 100, 7); + generateApiKey("Consumer3", 100); + + const status = getRotationStatus(); + expect(status.keys_with_scheduled_rotation).toBe(2); + expect(status.keys_pending_rotation).toBe(0); + expect(status.next_rotation_at).toBeDefined(); + }); + }); + + describe("Grace Period Rotation", () => { + it("should accept old key during grace period", () => { + const key = generateApiKey("Consumer", 100); + const oldKey = key.key; + const gracePeriodMs = 60 * 60 * 1000; // 1 hour + + rotateApiKey(key.id, gracePeriodMs); + + // Old key should still be valid + expect(validateApiKey(oldKey)).not.toBeNull(); + // New key should also be valid + const updatedKey = validateApiKey(key.key); + expect(updatedKey).not.toBeNull(); + }); + + it("should reject old key after grace period expires", () => { + const key = generateApiKey("Consumer", 100); + const oldKey = key.key; + + // Rotate with 0ms grace period (effectively no grace) + rotateApiKey(key.id, 0); + + // Old key should be invalid immediately + expect(validateApiKey(oldKey)).toBeNull(); + }); + }); + + describe("Rotation Notifications", () => { + it("should emit rotation notification", () => { + const notifications: any[] = []; + onRotation((n) => notifications.push(n)); + + const key = generateApiKey("Consumer", 100); + rotateApiKey(key.id); + + expect(notifications.length).toBe(1); + expect(notifications[0].key_id).toBe(key.id); + expect(notifications[0].consumer_name).toBe("Consumer"); + }); + }); + + describe("API Routes with Rotation", () => { + const app = express(); + app.use(express.json()); + app.use("/admin/api-keys", apiKeysRouter); + + it("should create key with rotation interval", async () => { + const res = await request(app) + .post("/admin/api-keys") + .set("Authorization", "Bearer admin-secret-key") + .send({ consumer_name: "Test Consumer", rotation_interval_days: 30 }); + + expect(res.status).toBe(201); + expect(res.body.rotation_interval_days).toBe(30); + expect(res.body.next_rotation_at).toBeDefined(); + }); + + it("should return rotation status", async () => { + generateApiKey("Consumer 1", 100, 30); + generateApiKey("Consumer 2", 100, 7); + + const res = await request(app) + .get("/admin/api-keys/rotation/status") + .set("Authorization", "Bearer admin-secret-key"); + + expect(res.status).toBe(200); + expect(res.body.keys_with_scheduled_rotation).toBe(2); + }); + + it("should trigger manual rotation", async () => { + const res = await request(app) + .post("/admin/api-keys/rotation/trigger") + .set("Authorization", "Bearer admin-secret-key"); + + expect(res.status).toBe(200); + expect(res.body.rotated).toBe(0); + }); + + it("should rotate key with grace period", async () => { + const key = generateApiKey("Consumer", 100); + + const res = await request(app) + .post(`/admin/api-keys/${key.id}/rotate`) + .set("Authorization", "Bearer admin-secret-key") + .send({ grace_period_ms: 3600000 }); + + expect(res.status).toBe(200); + expect(res.body.old_key).toBeDefined(); + expect(res.body.new_key_expires_at).toBeDefined(); + }); + + it("should include rotation info in key details", async () => { + const key = generateApiKey("Consumer", 100, 30); + + const res = await request(app) + .get(`/admin/api-keys/${key.id}/usage`) + .set("Authorization", "Bearer admin-secret-key"); + + expect(res.status).toBe(200); + expect(res.body.rotation_interval_days).toBe(30); + expect(res.body.next_rotation_at).toBeDefined(); + }); + }); +}); diff --git a/src/__tests__/ipWhitelist.test.ts b/src/__tests__/ipWhitelist.test.ts new file mode 100644 index 0000000..f8a24da --- /dev/null +++ b/src/__tests__/ipWhitelist.test.ts @@ -0,0 +1,134 @@ +import express from "express"; +import request from "supertest"; +import { ipWhitelist, refreshIPWhitelist } from "../middleware/ipWhitelist"; + +describe("IP Whitelist Middleware", () => { + let app: express.Express; + let originalEnv: NodeJS.ProcessEnv; + + beforeAll(() => { + originalEnv = { ...process.env }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + beforeEach(() => { + app = express(); + app.set("trust proxy", true); + app.use(ipWhitelist); + app.get("/test", (_req, res) => res.json({ success: true })); + }); + + afterEach(() => { + delete process.env.ADMIN_IP_WHITELIST; + delete process.env.ADMIN_IP_WHITELIST_BYPASS_PRIVATE; + refreshIPWhitelist(); + }); + + it("should allow all requests when whitelist is empty", async () => { + delete process.env.ADMIN_IP_WHITELIST; + refreshIPWhitelist(); + + const res = await request(app).get("/test"); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it("should block requests from non-whitelisted public IPs", async () => { + process.env.ADMIN_IP_WHITELIST = "192.168.1.100"; + process.env.ADMIN_IP_WHITELIST_BYPASS_PRIVATE = "false"; + refreshIPWhitelist(); + + const res = await request(app) + .get("/test") + .set("X-Forwarded-For", "8.8.8.8"); + + expect(res.status).toBe(403); + expect(res.body.error).toBe("forbidden"); + }); + + it("should allow requests from whitelisted IPs", async () => { + process.env.ADMIN_IP_WHITELIST = "10.0.0.1"; + refreshIPWhitelist(); + + const res = await request(app) + .get("/test") + .set("X-Forwarded-For", "10.0.0.1"); + + expect(res.status).toBe(200); + }); + + it("should support CIDR notation", async () => { + process.env.ADMIN_IP_WHITELIST = "10.0.0.0/24"; + refreshIPWhitelist(); + + const res = await request(app) + .get("/test") + .set("X-Forwarded-For", "10.0.0.50"); + + expect(res.status).toBe(200); + }); + + it("should block public IPs outside CIDR range", async () => { + process.env.ADMIN_IP_WHITELIST = "10.0.0.0/24"; + process.env.ADMIN_IP_WHITELIST_BYPASS_PRIVATE = "false"; + refreshIPWhitelist(); + + const res = await request(app) + .get("/test") + .set("X-Forwarded-For", "203.0.113.5"); + + expect(res.status).toBe(403); + }); + + it("should support multiple CIDR ranges", async () => { + process.env.ADMIN_IP_WHITELIST = "10.0.0.0/24,203.0.113.0/24"; + refreshIPWhitelist(); + + const res1 = await request(app) + .get("/test") + .set("X-Forwarded-For", "10.0.0.50"); + expect(res1.status).toBe(200); + + const res2 = await request(app) + .get("/test") + .set("X-Forwarded-For", "203.0.113.50"); + expect(res2.status).toBe(200); + }); + + it("should bypass private networks by default", async () => { + process.env.ADMIN_IP_WHITELIST = "1.2.3.4"; + refreshIPWhitelist(); + + const res = await request(app) + .get("/test") + .set("X-Forwarded-For", "192.168.1.100"); + + expect(res.status).toBe(200); + }); + + it("should not bypass private networks when configured", async () => { + process.env.ADMIN_IP_WHITELIST = "1.2.3.4"; + process.env.ADMIN_IP_WHITELIST_BYPASS_PRIVATE = "false"; + refreshIPWhitelist(); + + const res = await request(app) + .get("/test") + .set("X-Forwarded-For", "192.168.1.100"); + + expect(res.status).toBe(403); + }); + + it("should handle IPv4-mapped IPv6 addresses", async () => { + process.env.ADMIN_IP_WHITELIST = "10.0.0.1"; + refreshIPWhitelist(); + + const res = await request(app) + .get("/test") + .set("X-Forwarded-For", "::ffff:10.0.0.1"); + + expect(res.status).toBe(200); + }); +}); diff --git a/src/index.ts b/src/index.ts index 038113f..d7be4a2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -64,6 +64,8 @@ import { runWithCorrelationId, generateCorrelationId } from "./lib/correlation"; import { logger } from "./lib/logger"; import { getTraces, getTraceSummary } from "./lib/tracer"; import { withProjectLock } from "./lib/request-queue"; +import { checkScheduledRotations } from "./lib/apiKeys"; +import { ipWhitelist } from "./middleware/ipWhitelist"; dotenv.config(); const env = initEnv(); @@ -110,29 +112,29 @@ v1.use(versionHeaders); v1.use(acceptVersion); v1.use("/iot", publicLimiter, iotRouter); -v1.use("/admin", adminLimiter, adminRouter); -v1.use("/admin/batch", adminLimiter, batchRouter); +v1.use("/admin", ipWhitelist, adminLimiter, adminRouter); +v1.use("/admin/batch", ipWhitelist, adminLimiter, batchRouter); v1.use("/projects", publicLimiter, projectsRouter); v1.use("/projects/:id/history", publicLimiter, historyRouter); v1.use("/projects/aggregate", publicLimiter, aggregateRouter); v1.use("/portfolio", publicLimiter, portfolioRouter); -v1.use("/roles", adminLimiter, rolesRouter); -v1.use("/webhooks", adminLimiter, webhooksRouter); -v1.use("/panels", adminLimiter, panelsRouter); -v1.use("/metadata", adminLimiter, metadataRouter); +v1.use("/roles", ipWhitelist, adminLimiter, rolesRouter); +v1.use("/webhooks", ipWhitelist, adminLimiter, webhooksRouter); +v1.use("/panels", ipWhitelist, adminLimiter, panelsRouter); +v1.use("/metadata", ipWhitelist, adminLimiter, metadataRouter); v1.use("/dashboard", publicLimiter, dashboardRouter); -v1.use("/email", adminLimiter, emailRouter); +v1.use("/email", ipWhitelist, adminLimiter, emailRouter); v1.use("/anomaly", publicLimiter, anomalyRouter); -v1.use("/scoring/formulas", adminLimiter, scoringFormulasRouter); -v1.use("/chains", adminLimiter, chainsRouter); -v1.use("/satellite-sources", adminLimiter, satelliteSourcesRouter); +v1.use("/scoring/formulas", ipWhitelist, adminLimiter, scoringFormulasRouter); +v1.use("/chains", ipWhitelist, adminLimiter, chainsRouter); +v1.use("/satellite-sources", ipWhitelist, adminLimiter, satelliteSourcesRouter); v1.use("/comparison", publicLimiter, comparisonRouter); v1.use("/benchmarking", publicLimiter, benchmarkingRouter); v1.use("/financial", publicLimiter, financialRouter); v1.use("/forecast", publicLimiter, forecastRouter); v1.use("/maintenance", publicLimiter, maintenanceRouter); v1.use("/investor", publicLimiter, investorRouter); -v1.use("/admin/api-keys", adminLimiter, apiKeysRouter); +v1.use("/admin/api-keys", ipWhitelist, adminLimiter, apiKeysRouter); app.use("/v1", v1); @@ -140,25 +142,25 @@ app.use("/v1", v1); // Kept for backward compatibility; will be removed after 2027-01-01. app.use("/api", deprecationHeaders, versionHeaders); app.use("/api/iot", publicLimiter, iotRouter); -app.use("/api/admin", adminLimiter, adminRouter); -app.use("/api/admin/batch", adminLimiter, batchRouter); +app.use("/api/admin", ipWhitelist, adminLimiter, adminRouter); +app.use("/api/admin/batch", ipWhitelist, adminLimiter, batchRouter); app.use("/api/projects", publicLimiter, projectsRouter); app.use("/api/projects/:id/history", publicLimiter, historyRouter); app.use("/api/projects/aggregate", publicLimiter, aggregateRouter); app.use("/api/portfolio", publicLimiter, portfolioRouter); -app.use("/api/roles", adminLimiter, rolesRouter); -app.use("/api/webhooks", adminLimiter, webhooksRouter); -app.use("/api/panels", adminLimiter, panelsRouter); -app.use("/api/metadata", adminLimiter, metadataRouter); +app.use("/api/roles", ipWhitelist, adminLimiter, rolesRouter); +app.use("/api/webhooks", ipWhitelist, adminLimiter, webhooksRouter); +app.use("/api/panels", ipWhitelist, adminLimiter, panelsRouter); +app.use("/api/metadata", ipWhitelist, adminLimiter, metadataRouter); app.use("/api/dashboard", publicLimiter, dashboardRouter); -app.use("/api/email", adminLimiter, emailRouter); +app.use("/api/email", ipWhitelist, adminLimiter, emailRouter); app.use("/api/comparison", publicLimiter, comparisonRouter); app.use("/api/benchmarking", publicLimiter, benchmarkingRouter); app.use("/api/financial", publicLimiter, financialRouter); app.use("/api/forecast", publicLimiter, forecastRouter); app.use("/api/maintenance", publicLimiter, maintenanceRouter); app.use("/api/investor", publicLimiter, investorRouter); -app.use("/api/admin/api-keys", adminLimiter, apiKeysRouter); +app.use("/api/admin/api-keys", ipWhitelist, adminLimiter, apiKeysRouter); // JSON 404 for anything unmatched, then the structured error handler. app.use(notFoundHandler); @@ -379,6 +381,28 @@ cron.schedule( { timezone: CRON_TIMEZONE }, ); +// ── Cron: check API key rotations every hour ──────────────────────────────── +cron.schedule( + "0 * * * *", + () => { + try { + const rotated = checkScheduledRotations(); + if (rotated.length > 0) { + logger.info("[cron] API key rotations executed", { + count: rotated.length, + key_ids: rotated.map((k) => k.id), + }); + } + } catch (err: any) { + if (!isErrorRateLimited("cron:api-key-rotation")) { + logger.error("[cron] API key rotation check failed", { error: err?.message }); + } + recordCronRun("api-key-rotation", "error"); + } + }, + { timezone: CRON_TIMEZONE }, +); + const server = app.listen(PORT, () => { logger.info(`Heliobond backend listening on port ${PORT}`); }); diff --git a/src/lib/apiKeys.ts b/src/lib/apiKeys.ts index 4388f1e..6999547 100644 --- a/src/lib/apiKeys.ts +++ b/src/lib/apiKeys.ts @@ -9,6 +9,18 @@ export interface ApiKey { usage_count: number; last_used_at: number | null; created_at: number; + rotation_interval_days?: number; + next_rotation_at?: number; + old_key?: string; + new_key_expires_at?: number; + last_rotated_at?: number; +} + +export interface RotationNotification { + key_id: string; + consumer_name: string; + rotated_at: number; + old_key_expires_at: number; } // In-memory store for API keys @@ -17,9 +29,31 @@ const keysStore = new Map(); // In-memory rate limiting tracks: keyId -> { currentMinute, count } const rateLimitMap = new Map(); -export function generateApiKey(consumerName: string, rateLimit = 100): ApiKey { +// Rotation notification callbacks +const rotationCallbacks: Array<(notification: RotationNotification) => void> = []; + +export function onRotation(callback: (notification: RotationNotification) => void): void { + rotationCallbacks.push(callback); +} + +function emitRotationNotification(notification: RotationNotification): void { + for (const callback of rotationCallbacks) { + try { + callback(notification); + } catch { + // Ignore callback errors + } + } +} + +export function generateApiKey( + consumerName: string, + rateLimit = 100, + rotationIntervalDays?: number, +): ApiKey { const id = crypto.randomUUID(); const key = `hk_live_${crypto.randomBytes(24).toString("hex")}`; + const now = Date.now(); const apiKey: ApiKey = { id, key, @@ -28,19 +62,47 @@ export function generateApiKey(consumerName: string, rateLimit = 100): ApiKey { rate_limit: rateLimit, usage_count: 0, last_used_at: null, - created_at: Date.now(), + created_at: now, }; + + if (rotationIntervalDays && rotationIntervalDays > 0) { + apiKey.rotation_interval_days = rotationIntervalDays; + apiKey.next_rotation_at = now + rotationIntervalDays * 24 * 60 * 60 * 1000; + } + keysStore.set(id, apiKey); return apiKey; } -export function rotateApiKey(id: string): ApiKey | null { +export function rotateApiKey(id: string, gracePeriodMs?: number): ApiKey | null { const apiKey = keysStore.get(id); if (!apiKey || apiKey.status === "revoked") return null; const newKey = `hk_live_${crypto.randomBytes(24).toString("hex")}`; + const now = Date.now(); + + // Store old key for grace period if specified + if (gracePeriodMs && gracePeriodMs > 0) { + apiKey.old_key = apiKey.key; + apiKey.new_key_expires_at = now + gracePeriodMs; + } + apiKey.key = newKey; + apiKey.last_rotated_at = now; + + if (apiKey.rotation_interval_days) { + apiKey.next_rotation_at = now + apiKey.rotation_interval_days * 24 * 60 * 60 * 1000; + } + keysStore.set(id, apiKey); + + emitRotationNotification({ + key_id: id, + consumer_name: apiKey.consumer_name, + rotated_at: now, + old_key_expires_at: apiKey.new_key_expires_at ?? now, + }); + return apiKey; } @@ -62,10 +124,21 @@ export function getApiKeyDetails(id: string): ApiKey | null { } export function validateApiKey(key: string): ApiKey | null { + const now = Date.now(); for (const apiKey of keysStore.values()) { - if (apiKey.key === key && apiKey.status === "active") { + if (apiKey.status !== "active") continue; + + // Check current key + if (apiKey.key === key) { return apiKey; } + + // Check old key during grace period + if (apiKey.old_key && apiKey.new_key_expires_at && now < apiKey.new_key_expires_at) { + if (apiKey.old_key === key) { + return apiKey; + } + } } return null; } @@ -101,3 +174,50 @@ export function clearApiKeys(): void { keysStore.clear(); rateLimitMap.clear(); } + +export function checkScheduledRotations(): ApiKey[] { + const now = Date.now(); + const rotated: ApiKey[] = []; + + for (const apiKey of keysStore.values()) { + if (apiKey.status !== "active") continue; + if (!apiKey.next_rotation_at) continue; + if (now >= apiKey.next_rotation_at) { + const result = rotateApiKey(apiKey.id); + if (result) { + rotated.push(result); + } + } + } + + return rotated; +} + +export function getRotationStatus(): { + keys_with_scheduled_rotation: number; + keys_pending_rotation: number; + next_rotation_at: number | null; +} { + const now = Date.now(); + let keysWithScheduled = 0; + let keysPending = 0; + let nextRotationAt: number | null = null; + + for (const apiKey of keysStore.values()) { + if (apiKey.status !== "active") continue; + if (!apiKey.next_rotation_at) continue; + + keysWithScheduled++; + if (now >= apiKey.next_rotation_at) { + keysPending++; + } else if (nextRotationAt === null || apiKey.next_rotation_at < nextRotationAt) { + nextRotationAt = apiKey.next_rotation_at; + } + } + + return { + keys_with_scheduled_rotation: keysWithScheduled, + keys_pending_rotation: keysPending, + next_rotation_at: nextRotationAt, + }; +} diff --git a/src/middleware/ipWhitelist.ts b/src/middleware/ipWhitelist.ts new file mode 100644 index 0000000..9085b24 --- /dev/null +++ b/src/middleware/ipWhitelist.ts @@ -0,0 +1,107 @@ +import { Request, Response, NextFunction } from "express"; +import { logger } from "../lib/logger"; + +interface CidrRange { + network: string; + prefix: number; +} + +function ipToNumber(ip: string): number { + const parts = ip.split(".").map(Number); + return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0; +} + +function cidrMatch(ip: string, cidr: CidrRange): boolean { + const ipNum = ipToNumber(ip); + const networkNum = ipToNumber(cidr.network); + const mask = ~((1 << (32 - cidr.prefix)) - 1) >>> 0; + return (ipNum & mask) === (networkNum & mask); +} + +function parseCidr(cidrString: string): CidrRange { + const [network, prefixStr] = cidrString.trim().split("/"); + const prefix = prefixStr ? parseInt(prefixStr, 10) : 32; + + if (!/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(network)) { + throw new Error(`Invalid IP address: ${network}`); + } + + if (prefix < 0 || prefix > 32) { + throw new Error(`Invalid CIDR prefix: ${prefix}`); + } + + return { network, prefix }; +} + +function isPrivateIP(ip: string): boolean { + const parts = ip.split(".").map(Number); + if (parts[0] === 10) return true; + if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; + if (parts[0] === 192 && parts[1] === 168) return true; + if (parts[0] === 127) return true; + if (parts[0] === 0) return true; + return false; +} + +function parseIPList(envValue: string | undefined): CidrRange[] { + if (!envValue) return []; + return envValue + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0) + .map(parseCidr); +} + +let allowedIPs: CidrRange[] = []; +let bypassPrivateNetworks = true; + +function reloadConfig(): void { + allowedIPs = parseIPList(process.env.ADMIN_IP_WHITELIST); + bypassPrivateNetworks = process.env.ADMIN_IP_WHITELIST_BYPASS_PRIVATE !== "false"; +} + +reloadConfig(); + +export function refreshIPWhitelist(): void { + reloadConfig(); +} + +export function ipWhitelist(req: Request, res: Response, next: NextFunction): void { + if (allowedIPs.length === 0) { + return next(); + } + + const clientIP = + (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() || + req.headers["x-real-ip"] as string || + req.socket.remoteAddress || + ""; + + const normalizedIP = clientIP.replace(/^::ffff:/, ""); + + if (bypassPrivateNetworks && isPrivateIP(normalizedIP)) { + return next(); + } + + const isAllowed = allowedIPs.some((cidr) => { + if (cidr.prefix === 32) { + return normalizedIP === cidr.network; + } + return cidrMatch(normalizedIP, cidr); + }); + + if (isAllowed) { + return next(); + } + + logger.warn("Admin request blocked by IP whitelist", { + ip: normalizedIP, + path: req.originalUrl, + method: req.method, + }); + + res.status(403).json({ + error: "forbidden", + message: "Your IP address is not authorized to access admin endpoints", + }); +} diff --git a/src/routes/apiKeys.ts b/src/routes/apiKeys.ts index 1054017..141a74d 100644 --- a/src/routes/apiKeys.ts +++ b/src/routes/apiKeys.ts @@ -5,8 +5,12 @@ import { revokeApiKey, listApiKeys, getApiKeyDetails, + checkScheduledRotations, + getRotationStatus, + onRotation, } from "../lib/apiKeys"; import { badRequest } from "../middleware/errors"; +import { logger } from "../lib/logger"; const router = Router(); @@ -20,10 +24,19 @@ router.use((req: Request, res: Response, next: NextFunction) => { next(); }); +// Register rotation notification logging +onRotation((notification) => { + logger.info("API key rotated", { + key_id: notification.key_id, + consumer_name: notification.consumer_name, + old_key_expires_at: new Date(notification.old_key_expires_at).toISOString(), + }); +}); + // POST / — Generate a new API key router.post("/", (req: Request, res: Response, next: NextFunction) => { try { - const { consumer_name, rate_limit } = req.body; + const { consumer_name, rate_limit, rotation_interval_days } = req.body; if (!consumer_name || typeof consumer_name !== "string") { throw badRequest("consumer_name is required and must be a string"); } @@ -36,8 +49,16 @@ router.post("/", (req: Request, res: Response, next: NextFunction) => { } } - const keyInfo = generateApiKey(consumer_name, parsedRateLimit); - res.status(201).json(keyInfo); // Returning 201 Created or custom success + let parsedRotationInterval: number | undefined; + if (rotation_interval_days !== undefined) { + parsedRotationInterval = Number(rotation_interval_days); + if (!Number.isInteger(parsedRotationInterval) || parsedRotationInterval <= 0) { + throw badRequest("rotation_interval_days must be a positive integer"); + } + } + + const keyInfo = generateApiKey(consumer_name, parsedRateLimit, parsedRotationInterval); + res.status(201).json(keyInfo); } catch (error) { next(error); } @@ -53,11 +74,47 @@ router.get("/", (_req: Request, res: Response, next: NextFunction) => { } }); +// GET /rotation/status — Get rotation status for all keys +router.get("/rotation/status", (_req: Request, res: Response, next: NextFunction) => { + try { + const status = getRotationStatus(); + res.json(status); + } catch (error) { + next(error); + } +}); + +// POST /rotation/trigger — Manually trigger scheduled rotations +router.post("/rotation/trigger", (_req: Request, res: Response, next: NextFunction) => { + try { + const rotated = checkScheduledRotations(); + res.json({ + rotated: rotated.length, + keys: rotated.map((k) => ({ + id: k.id, + consumer_name: k.consumer_name, + next_rotation_at: k.next_rotation_at, + })), + }); + } catch (error) { + next(error); + } +}); + // POST /:id/rotate — Rotate an API key router.post("/:id/rotate", (req: Request, res: Response, next: NextFunction) => { try { const id = String(req.params.id); - const rotated = rotateApiKey(id); + const body = req.body || {}; + const grace_period_ms = body.grace_period_ms + ? Number(body.grace_period_ms) + : undefined; + + if (grace_period_ms !== undefined && (grace_period_ms < 0 || !Number.isFinite(grace_period_ms))) { + throw badRequest("grace_period_ms must be a non-negative number"); + } + + const rotated = rotateApiKey(id, grace_period_ms); if (!rotated) { return res.status(404).json({ error: "not_found", message: "Active API key not found" }); } @@ -95,6 +152,9 @@ router.get("/:id/usage", (req: Request, res: Response, next: NextFunction) => { usage_count: keyDetails.usage_count, last_used_at: keyDetails.last_used_at, rate_limit: keyDetails.rate_limit, + rotation_interval_days: keyDetails.rotation_interval_days, + next_rotation_at: keyDetails.next_rotation_at, + last_rotated_at: keyDetails.last_rotated_at, }); } catch (error) { next(error);