Skip to content
Open
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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,24 @@ Node.js is pinned to **20.x LTS** in CI, which satisfies the `engines >= 18.18`

## Security / dependency update policy

### Runtime response headers

The API installs Helmet on every route before request IDs, API-key recognition,
rate limiting, or feature routers run. The configured policy preserves the
existing HSTS preload policy, `X-Frame-Options: DENY`,
`X-Content-Type-Options: nosniff`, and `Referrer-Policy: no-referrer` behavior
while adding a JSON-API-oriented `Content-Security-Policy`:

- `default-src 'none'` denies document subresources by default.
- `frame-ancestors 'none'` prevents the API from being embedded in frames.
- explicit `script-src 'none'`, `style-src 'none'`, and related directives keep
inline script/style and `eval` unavailable if a browser renders an API
response.

`Permissions-Policy` stays explicit at
`geolocation=(), camera=(), microphone=()`. The policy is applied to JSON
responses, CSV/JSON downloads, and Prometheus metrics text exposition.

### Vulnerability audit

Every CI run executes `npm audit --audit-level=high`. A **high** or **critical** advisory blocks the build and must be resolved before merging.
Expand Down
15 changes: 14 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"typescript-eslint": "^8.61.1"
},
"dependencies": {
"express": "^5.2.1"
"express": "^5.2.1",
"helmet": "^8.2.0"
}
}
47 changes: 38 additions & 9 deletions src/middleware/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import express, {
type Request,
type Response,
} from "express";
import helmet from "helmet";
import {
apiKeyStore,
pauseState,
Expand All @@ -22,6 +23,7 @@ export function installPreRouteMiddleware(app: Application): void {
app.use(createCorsMiddleware());
app.use(express.json({ limit: "100kb" }));
app.use(securityHeadersMiddleware);
app.use(permissionsPolicyMiddleware);
app.use(requestIdMiddleware);
}

Expand Down Expand Up @@ -68,19 +70,46 @@ function createCorsMiddleware() {
};
}

/** Adds the minimal hardening headers used by the original app. */
function securityHeadersMiddleware(
/**
* Helmet owns the standard response hardening header set. The CSP is tuned for
* this JSON API surface: no document subresources are expected, framing is
* denied, and explicit script/style directives avoid inline or eval fallbacks.
*/
const securityHeadersMiddleware = helmet({
contentSecurityPolicy: {
useDefaults: false,
directives: {
defaultSrc: ["'none'"],
baseUri: ["'none'"],
connectSrc: ["'none'"],
fontSrc: ["'none'"],
formAction: ["'none'"],
frameAncestors: ["'none'"],
imgSrc: ["'none'"],
manifestSrc: ["'none'"],
mediaSrc: ["'none'"],
objectSrc: ["'none'"],
scriptSrc: ["'none'"],
scriptSrcAttr: ["'none'"],
styleSrc: ["'none'"],
workerSrc: ["'none'"],
},
},
referrerPolicy: { policy: "no-referrer" },
strictTransportSecurity: {
maxAge: 63_072_000,
includeSubDomains: true,
preload: true,
},
xFrameOptions: { action: "deny" },
});

/** Keeps the API's explicit browser feature restrictions. */
function permissionsPolicyMiddleware(
_req: Request,
res: Response,
next: NextFunction
): void {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("Referrer-Policy", "no-referrer");
res.setHeader(
"Strict-Transport-Security",
"max-age=63072000; includeSubDomains; preload"
);
res.setHeader("Permissions-Policy", "geolocation=(), camera=(), microphone=()");
next();
}
Expand Down
66 changes: 66 additions & 0 deletions src/security-headers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import request, { type Response } from "supertest";
import { app } from "./index.js";

function assertSecurityHeaders(res: Response): void {
assert.strictEqual(res.headers["x-content-type-options"], "nosniff");
assert.strictEqual(res.headers["x-frame-options"], "DENY");
assert.strictEqual(res.headers["referrer-policy"], "no-referrer");
assert.strictEqual(
res.headers["strict-transport-security"],
"max-age=63072000; includeSubDomains; preload"
);
assert.strictEqual(
res.headers["permissions-policy"],
"geolocation=(), camera=(), microphone=()"
);

const csp = res.headers["content-security-policy"];
assert.ok(csp, "Content-Security-Policy header missing");
assert.match(csp, /default-src 'none'/);
assert.match(csp, /frame-ancestors 'none'/);
assert.match(csp, /script-src 'none'/);
assert.doesNotMatch(csp, /'unsafe-inline'/);
assert.doesNotMatch(csp, /'unsafe-eval'/);
}

void describe("Helmet security headers", () => {
void it("adds hardened security headers to JSON API responses", async () => {
const res = await request(app).get("/api/v1/stats");

assert.strictEqual(res.status, 200);
assert.match(res.headers["content-type"], /application\/json/);
assertSecurityHeaders(res);
});

void it("keeps hardened headers on CSV and JSON download responses", async () => {
const csv = await request(app).get("/api/v1/usage/export.csv");
assert.strictEqual(csv.status, 200);
assert.match(csv.headers["content-type"], /text\/csv/);
assert.strictEqual(
csv.headers["content-disposition"],
"attachment; filename=usage.csv"
);
assert.match(csv.text, /^agent,serviceId,total\n/);
assertSecurityHeaders(csv);

const json = await request(app).get("/api/v1/usage/export.json");
assert.strictEqual(json.status, 200);
assert.match(json.headers["content-type"], /application\/json/);
assert.strictEqual(
json.headers["content-disposition"],
"attachment; filename=usage.json"
);
assertSecurityHeaders(json);
});

void it("does not interfere with Prometheus metrics exposition", async () => {
const res = await request(app).get("/api/v1/metrics");

assert.strictEqual(res.status, 200);
assert.match(res.headers["content-type"], /text\/plain/);
assert.match(res.text, /# HELP agentpay_services_total/);
assertSecurityHeaders(res);
});
});