diff --git a/README.md b/README.md index 7bb9642..5a75cd8 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,49 @@ if (!verification.ok) { - structured JSON logs are expected for diagnostics - if `indexer.status = "stalled"`, treat that as an operational signal that chain-derived views would be stale if the real indexer were enabled in this service +## Security headers: helmet middleware + +### Service-level outcomes + +- every HTTP response carries a predictable baseline of browser-facing security headers +- the service does not advertise Express internals through the `X-Powered-By` header +- operators can verify the header policy with a simple `GET /health` or `GET /` check during rollout and incident response +- failures in downstream dependencies do not disable the security-header baseline because the middleware is applied before route handling + +### Trust boundaries + +| Actor | May do | May not do | +|-------|--------|------------| +| Public internet clients | Call public routes and observe the documented response headers | Weaken or negotiate a lower security-header policy | +| Authenticated partners | Use partner/admin routes once enabled and receive the same baseline headers | Bypass the default browser-hardening behavior | +| Administrators / operators | Verify header presence through health checks, logs, and smoke tests | Treat the presence of headers as a substitute for auth, input validation, or TLS termination controls | +| Internal workers | Reach internal HTTP surfaces through the same Express stack when applicable | Disable header emission on a per-worker basis | + +### Failure modes and expected behavior + +| Condition | Expected behavior | +|-----------|-------------------| +| Invalid input or route error | Return the normal error status/body and still emit the security headers | +| Dependency outage | `/health` may report degraded or unavailable state, but the header baseline remains present on the HTTP response | +| Partial data or missing resources | Client receives the documented `404`/`409`/`5xx` behavior with the same header policy intact | +| Duplicate delivery or replayed request | Business logic decides `200`/`409` behavior; the security headers are unchanged because they are orthogonal to idempotency | + +### Operator observability and diagnostics + +- smoke check with `curl -I http://127.0.0.1:3000/health` and confirm `content-security-policy`, `strict-transport-security`, `x-content-type-options`, and `x-frame-options` +- use structured request logs and request IDs to correlate header checks with the request path under investigation +- if a proxy or CDN strips headers, compare direct-app responses with edge responses to identify where the policy is being altered + +### Verification evidence + +- automated regression coverage lives in `tests/helmet.test.ts` +- manual verification: `curl -I http://127.0.0.1:3000/` and `curl -I http://127.0.0.1:3000/health` + +### Non-goals and audit notes + +- this issue adds baseline browser-facing security headers only; it does not replace TLS, authentication, authorization, rate limiting, or CSP tuning for a future browser UI +- residual risk: intermediaries can still overwrite or strip headers, so production verification should include at least one edge-facing probe + ## Local setup ### Prerequisites diff --git a/openapi.yaml b/openapi.yaml index bb4c333..6435df3 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -7,6 +7,16 @@ info: Presents operator-grade reliability with predictable HTTP semantics, durable views of chain-derived data, and explicit failure behavior when dependencies are unhealthy. + + ## Security Header Baseline + Fluxora applies Helmet at the Express boundary so every HTTP response carries a consistent + browser-facing security-header baseline, including `Content-Security-Policy`, + `Strict-Transport-Security`, `X-Content-Type-Options`, `X-Frame-Options`, and + `Referrer-Policy`. The service also suppresses the `X-Powered-By` header. + + These headers are transport invariants for public clients, authenticated partners, + administrators, and internal workers that traverse the HTTP surface. They do not replace + authentication, authorization, TLS termination, or proxy hardening. ## Trust Boundaries - **Public Internet Clients**: Read-only access to stream listings and health checks diff --git a/package.json b/package.json index a5f2f4b..1ebe7eb 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dependencies": { "better-sqlite3": "^12.8.0", "express": "^4.18.2", + "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "pg": "^8.20.0", "stellar-sdk": "^13.3.0", diff --git a/src/app.ts b/src/app.ts index 3a7b46c..9ceef28 100644 --- a/src/app.ts +++ b/src/app.ts @@ -88,3 +88,5 @@ export function createApp(): express.Express { return app; } + +export const app = createApp(); diff --git a/src/config/env.ts b/src/config/env.ts index a7142d2..de6b26e 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,4 +1,5 @@ -import { StellarNetwork, STELLAR_NETWORKS, ContractAddresses } from './stellar.js'; +import { type StellarNetwork, STELLAR_NETWORKS, type ContractAddresses } from './stellar.js'; +export { STELLAR_NETWORKS, type StellarNetwork, type ContractAddresses } from './stellar.js'; /** * Global configuration interface for the Fluxora API. @@ -52,6 +53,15 @@ export interface Config { // Feature flags enableStreamValidation: boolean; enableRateLimit: boolean; + requirePartnerAuth: boolean; + partnerApiToken?: string | undefined; + requireAdminAuth: boolean; + adminApiToken?: string | undefined; + indexerEnabled: boolean; + workerEnabled: boolean; + indexerStallThresholdMs: number; + indexerLastSuccessfulSyncAt?: string | undefined; + deploymentChecklistVersion: string; } /** @@ -259,6 +269,15 @@ export function loadConfig(): Config { enableStreamValidation: parseBoolEnv(process.env.ENABLE_STREAM_VALIDATION, true), enableRateLimit: parseBoolEnv(process.env.ENABLE_RATE_LIMIT, !isProduction), + requirePartnerAuth: parseBoolEnv(process.env.REQUIRE_PARTNER_AUTH, false), + partnerApiToken: process.env.PARTNER_API_TOKEN, + requireAdminAuth: parseBoolEnv(process.env.REQUIRE_ADMIN_AUTH, false), + adminApiToken: process.env.ADMIN_API_TOKEN, + indexerEnabled: parseBoolEnv(process.env.INDEXER_ENABLED, false), + workerEnabled: parseBoolEnv(process.env.WORKER_ENABLED, false), + indexerStallThresholdMs: parseIntEnv(process.env.INDEXER_STALL_THRESHOLD_MS, 5 * 60 * 1000, 1000), + indexerLastSuccessfulSyncAt: process.env.INDEXER_LAST_SUCCESSFUL_SYNC_AT, + deploymentChecklistVersion: process.env.DEPLOYMENT_CHECKLIST_VERSION ?? '2026-03-27', }; return config; diff --git a/src/config/stellar.ts b/src/config/stellar.ts new file mode 100644 index 0000000..089894a --- /dev/null +++ b/src/config/stellar.ts @@ -0,0 +1,24 @@ +export type StellarNetwork = 'testnet' | 'mainnet'; + +export interface ContractAddresses { + streaming: string; +} + +interface StellarNetworkDefaults { + horizonUrl: string; + passphrase: string; + streamingContractAddress: string; +} + +export const STELLAR_NETWORKS: Record = { + testnet: { + horizonUrl: 'https://horizon-testnet.stellar.org', + passphrase: 'Test SDF Network ; September 2015', + streamingContractAddress: 'PLACEHOLDER_TESTNET_STREAMING_CONTRACT', + }, + mainnet: { + horizonUrl: 'https://horizon.stellar.org', + passphrase: 'Public Global Stellar Network ; September 2015', + streamingContractAddress: 'PLACEHOLDER_MAINNET_STREAMING_CONTRACT', + }, +}; diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 8e74f46..c33ca27 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -1,5 +1,6 @@ import { Request, Response, NextFunction } from 'express'; import { verifyToken, UserPayload } from '../lib/auth.js'; +import { getApiKeyFromRequest, isValidApiKey } from '../lib/apiKey.js'; import { ApiError, ApiErrorCode } from './errorHandler.js'; import { warn, info, debug } from '../utils/logger.js'; @@ -12,6 +13,7 @@ import { warn, info, debug } from '../utils/logger.js'; export function authenticate(req: Request, res: Response, next: NextFunction): void { const authHeader = req.headers.authorization; const requestId = (req as any).id || (req as any).correlationId; + const apiKey = getApiKeyFromRequest(req.headers as Record); debug('Authentication middleware triggered', { hasAuthHeader: !!authHeader, requestId }); diff --git a/tests/helmet.test.ts b/tests/helmet.test.ts index 9aa4b7d..61cab8c 100644 --- a/tests/helmet.test.ts +++ b/tests/helmet.test.ts @@ -1,60 +1,90 @@ import { describe, it, expect } from 'vitest'; -import request from 'supertest'; +import { Duplex } from 'node:stream'; +import { IncomingMessage, ServerResponse } from 'node:http'; import { app } from '../src/app.js'; +async function performRequest(path: string): Promise> { + const socket = new Duplex({ + read() {}, + write(_chunk, _encoding, callback) { + callback(); + }, + }); + + const req = new IncomingMessage(socket); + req.method = 'GET'; + req.url = path; + req.headers = {}; + + const res = new ServerResponse(req); + res.assignSocket(socket); + + return await new Promise((resolve, reject) => { + res.on('finish', () => { + resolve({ + status: res.statusCode, + ...res.getHeaders(), + }); + }); + res.on('error', reject); + + app.handle(req, res, reject); + }); +} + describe('helmet security headers', () => { it('sets Content-Security-Policy header', async () => { - const res = await request(app).get('/'); - expect(res.headers['content-security-policy']).toBeDefined(); + const res = await performRequest('/'); + expect(res['content-security-policy']).toBeDefined(); }); it('sets X-Content-Type-Options to nosniff', async () => { - const res = await request(app).get('/'); - expect(res.headers['x-content-type-options']).toBe('nosniff'); + const res = await performRequest('/'); + expect(res['x-content-type-options']).toBe('nosniff'); }); it('sets X-Frame-Options to SAMEORIGIN', async () => { - const res = await request(app).get('/'); - expect(res.headers['x-frame-options']).toBe('SAMEORIGIN'); + const res = await performRequest('/'); + expect(res['x-frame-options']).toBe('SAMEORIGIN'); }); it('removes X-Powered-By header', async () => { - const res = await request(app).get('/'); - expect(res.headers['x-powered-by']).toBeUndefined(); + const res = await performRequest('/'); + expect(res['x-powered-by']).toBeUndefined(); }); it('sets Strict-Transport-Security header', async () => { - const res = await request(app).get('/'); - expect(res.headers['strict-transport-security']).toBeDefined(); + const res = await performRequest('/'); + expect(res['strict-transport-security']).toBeDefined(); }); it('sets X-DNS-Prefetch-Control header', async () => { - const res = await request(app).get('/'); - expect(res.headers['x-dns-prefetch-control']).toBe('off'); + const res = await performRequest('/'); + expect(res['x-dns-prefetch-control']).toBe('off'); }); it('sets X-Download-Options header', async () => { - const res = await request(app).get('/'); - expect(res.headers['x-download-options']).toBe('noopen'); + const res = await performRequest('/'); + expect(res['x-download-options']).toBe('noopen'); }); it('sets X-Permitted-Cross-Domain-Policies header', async () => { - const res = await request(app).get('/'); - expect(res.headers['x-permitted-cross-domain-policies']).toBe('none'); + const res = await performRequest('/'); + expect(res['x-permitted-cross-domain-policies']).toBe('none'); }); it('sets Referrer-Policy header', async () => { - const res = await request(app).get('/'); - expect(res.headers['referrer-policy']).toBeDefined(); + const res = await performRequest('/'); + expect(res['referrer-policy']).toBeDefined(); }); it('applies headers to all routes', async () => { const routes = ['/health', '/api/streams', '/']; for (const route of routes) { - const res = await request(app).get(route); - expect(res.headers['x-content-type-options']).toBe('nosniff'); - expect(res.headers['x-frame-options']).toBe('SAMEORIGIN'); - expect(res.headers['x-powered-by']).toBeUndefined(); + const res = await performRequest(route); + expect(res['x-content-type-options']).toBe('nosniff'); + expect(res['x-frame-options']).toBe('SAMEORIGIN'); + expect(res['x-powered-by']).toBeUndefined(); } }); }); diff --git a/tsconfig.json b/tsconfig.json index 46d6f95..62d0cdf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,5 +20,6 @@ "forceConsistentCasingInFileNames": true, "downlevelIteration": true }, - "include": ["src/**/*"] + "include": ["src/**/*"], + "exclude": ["src/**/*.test.ts"] }