Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,5 @@ export function createApp(): express.Express {

return app;
}

export const app = createApp();
21 changes: 20 additions & 1 deletion src/config/env.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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;
Expand Down
24 changes: 24 additions & 0 deletions src/config/stellar.ts
Original file line number Diff line number Diff line change
@@ -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<StellarNetwork, StellarNetworkDefaults> = {
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',
},
};
2 changes: 2 additions & 0 deletions src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<string, string | string[] | undefined>);

debug('Authentication middleware triggered', { hasAuthHeader: !!authHeader, requestId });

Expand Down
76 changes: 53 additions & 23 deletions tests/helmet.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string | string[] | number>> {
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();
}
});
});
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@
"forceConsistentCasingInFileNames": true,
"downlevelIteration": true
},
"include": ["src/**/*"]
"include": ["src/**/*"],
"exclude": ["src/**/*.test.ts"]
}
Loading