From 28140b6a095c23fd54deb6098528e8f1419b5ed1 Mon Sep 17 00:00:00 2001 From: Stefan Meinecke Date: Thu, 18 Jun 2026 14:45:34 +0200 Subject: [PATCH] feat: security validations, compose hardening, integration tests, DX improvements - Enforce minimum SECRET length (32 chars) in parseApiConfig - Validate ALGORITHM against PBKDF2 whitelist (SHA-256/384/512) - Return distinguishable JSON error bodies from /verify: { error: "invalid" } vs { error: "replayed" } - Make demo service opt-in via Compose profile (--profile demo) - Add restart: unless-stopped and resource limits to compose.yaml - Add HTTP-level integration tests for api-app.ts (native fetch) - Move test glob to bunfig.toml for shell-independent discovery - Add lightweight request logging middleware (disabled in test) --- README.md | 9 +++- bunfig.toml | 2 + compose.yaml | 14 ++++++ package.json | 2 +- src/api-app.test.ts | 104 ++++++++++++++++++++++++++++++++++++++++++++ src/api-app.ts | 20 ++++++++- src/config.test.ts | 40 ++++++++++++----- src/config.ts | 21 ++++++++- 8 files changed, 195 insertions(+), 17 deletions(-) create mode 100644 bunfig.toml create mode 100644 src/api-app.test.ts diff --git a/README.md b/README.md index 68e3c4b..7c1fcf7 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,18 @@ Use Docker Compose (recommended): ```bash # Optionally create a .env file (see below) or set variables in your shell [ -f .env ] || cp .env.example .env -# Start the stack +# Start the API only (production default) docker compose up --build ``` - API: http://localhost:3000 + +To also start the demo service, add the `demo` profile: + +```bash +docker compose --profile demo up --build +``` + - Demo: http://localhost:8080 To override the secret temporarily: diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..da0cf22 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +include = ["src/**/*.test.ts"] diff --git a/compose.yaml b/compose.yaml index f52201e..1478729 100644 --- a/compose.yaml +++ b/compose.yaml @@ -10,8 +10,16 @@ services: MAXNUMBER: ${MAXNUMBER:-5000} ports: - "3000:3000" + restart: unless-stopped + deploy: + resources: + limits: + cpus: "0.5" + memory: 256M demo: + profiles: + - demo build: context: . target: demo @@ -22,3 +30,9 @@ services: - server ports: - "8080:8080" + restart: unless-stopped + deploy: + resources: + limits: + cpus: "0.5" + memory: 256M diff --git a/package.json b/package.json index cae535e..1783d51 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "bun --watch ./src/index.ts", "start": "bun ./build/index.js", "start:demo": "bun ./build/demo.js", - "test": "bun test src/**/*.test.ts", + "test": "bun test", "tsc": "tsc" }, "keywords": [], diff --git a/src/api-app.test.ts b/src/api-app.test.ts new file mode 100644 index 0000000..40f7738 --- /dev/null +++ b/src/api-app.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test, beforeAll, afterAll } from "bun:test"; +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { solveChallenge } from "altcha-lib"; +import { deriveKey } from "altcha-lib/algorithms/pbkdf2"; +import { createApiApp } from "./api-app"; +import type { ApiConfig } from "./config"; + +const TEST_SECRET = "this-is-a-very-long-secret-key-that-is-at-least-32-chars"; + +const testConfig: ApiConfig = { + algorithm: "PBKDF2/SHA-256", + corsOrigin: "*", + expireMinutes: 10, + hmacKey: TEST_SECRET, + maxNumber: 100, + maxRecords: 1000, + port: 0, +}; + +describe("createApiApp", () => { + let app: Awaited>; + let server: Server; + let baseUrl: string; + + beforeAll(async () => { + app = await createApiApp(testConfig); + server = app.listen(0) as Server; + const address = server.address() as AddressInfo; + baseUrl = `http://localhost:${address.port}`; + }); + + afterAll(() => { + server.close(); + }); + + test("GET / returns 204", async () => { + const res = await fetch(`${baseUrl}/`); + expect(res.status).toBe(204); + }); + + test("GET /challenge returns 200 with challenge shape", async () => { + const res = await fetch(`${baseUrl}/challenge`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty("parameters"); + expect(body).toHaveProperty("signature"); + expect(typeof body.parameters).toBe("object"); + expect(typeof body.signature).toBe("string"); + expect(body.parameters).toHaveProperty("algorithm"); + expect(body.parameters).toHaveProperty("nonce"); + expect(body.parameters).toHaveProperty("salt"); + }); + + test("GET /verify with valid payload returns 202", async () => { + const challengeRes = await fetch(`${baseUrl}/challenge`); + const challenge = await challengeRes.json(); + + const solution = await solveChallenge({ + challenge, + deriveKey, + }); + expect(solution).not.toBeNull(); + + const payload = btoa(JSON.stringify({ challenge, solution })); + const verifyRes = await fetch(`${baseUrl}/verify?altcha=${encodeURIComponent(payload)}`); + expect(verifyRes.status).toBe(202); + }); + + test("GET /verify with invalid base64 returns 417 and error invalid", async () => { + const res = await fetch(`${baseUrl}/verify?altcha=not-valid-base64!!!`); + expect(res.status).toBe(417); + const body = await res.json(); + expect(body).toEqual({ error: "invalid" }); + }); + + test("GET /verify without altcha query returns 417 and error invalid", async () => { + const res = await fetch(`${baseUrl}/verify`); + expect(res.status).toBe(417); + const body = await res.json(); + expect(body).toEqual({ error: "invalid" }); + }); + + test("GET /verify with replayed payload returns 417 and error replayed", async () => { + const challengeRes = await fetch(`${baseUrl}/challenge`); + const challenge = await challengeRes.json(); + + const solution = await solveChallenge({ + challenge, + deriveKey, + }); + expect(solution).not.toBeNull(); + + const payload = btoa(JSON.stringify({ challenge, solution })); + + const first = await fetch(`${baseUrl}/verify?altcha=${encodeURIComponent(payload)}`); + expect(first.status).toBe(202); + + const second = await fetch(`${baseUrl}/verify?altcha=${encodeURIComponent(payload)}`); + expect(second.status).toBe(417); + const body = await second.json(); + expect(body).toEqual({ error: "replayed" }); + }); +}); diff --git a/src/api-app.ts b/src/api-app.ts index 702ed22..3234120 100644 --- a/src/api-app.ts +++ b/src/api-app.ts @@ -30,6 +30,17 @@ export const createApiApp = async (config: ApiConfig): Promise => { app.use(express.json()); app.use(cors({ origin: config.corsOrigin })); + if (process.env.NODE_ENV !== "test") { + app.use((req: Request, res: Response, next: NextFunction) => { + const start = Date.now(); + res.on("finish", () => { + const duration = Date.now() - start; + console.log(`${req.method} ${req.path} ${res.statusCode} ${duration}ms`); + }); + next(); + }); + } + app.get("/", (_req: Request, res: Response) => { res.sendStatus(204); }); @@ -57,12 +68,17 @@ export const createApiApp = async (config: ApiConfig): Promise => { app.get("/verify", asyncHandler(async (req: Request, res: Response) => { const payload = req.query.altcha; if (typeof payload !== "string" || !payload.length) { - res.sendStatus(417); + res.status(417).json({ error: "invalid" }); return; } const result = await verifyPayload(payload, deriveKey, config.hmacKey, hmacKeySignatureSecret, replayStore); - res.sendStatus(result.error ? 417 : 202); + if (result.error) { + const error = result.error === "ALTCHA payload has been already used." ? "replayed" : "invalid"; + res.status(417).json({ error }); + } else { + res.sendStatus(202); + } })); return app; diff --git a/src/config.test.ts b/src/config.test.ts index 2ceb1c6..99ebee8 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,15 +1,17 @@ import { describe, expect, test } from "bun:test"; import { parseApiConfig, parseDemoConfig } from "./config"; +const LONG_SECRET = "this-is-a-very-long-secret-key-that-is-at-least-32-chars"; + describe("parseApiConfig", () => { test("uses defaults for optional API config", () => { - const config = parseApiConfig({ SECRET: "local-secret" }); + const config = parseApiConfig({ SECRET: LONG_SECRET }); expect(config).toEqual({ algorithm: "PBKDF2/SHA-256", corsOrigin: "*", expireMinutes: 10, - hmacKey: "local-secret", + hmacKey: LONG_SECRET, maxNumber: 5000, maxRecords: 1000, port: 3000, @@ -18,26 +20,26 @@ describe("parseApiConfig", () => { test("parses numeric and string API config", () => { const config = parseApiConfig({ - ALGORITHM: "SHA-256", + ALGORITHM: "PBKDF2/SHA-256", CORS_ORIGIN: "https://example.com, https://admin.example.com", EXPIREMINUTES: "5", MAXNUMBER: "9000", MAXRECORDS: "25", PORT: "4000", - SECRET: "configured-secret", + SECRET: LONG_SECRET, }); - expect(config.algorithm).toBe("SHA-256"); + expect(config.algorithm).toBe("PBKDF2/SHA-256"); expect(config.corsOrigin).toEqual(["https://example.com", "https://admin.example.com"]); expect(config.expireMinutes).toBe(5); expect(config.maxNumber).toBe(9000); expect(config.maxRecords).toBe(25); expect(config.port).toBe(4000); - expect(config.hmacKey).toBe("configured-secret"); + expect(config.hmacKey).toBe(LONG_SECRET); }); test("supports COST as legacy fallback when MAXNUMBER is absent", () => { - const config = parseApiConfig({ COST: "7000", SECRET: "configured-secret" }); + const config = parseApiConfig({ COST: "7000", SECRET: LONG_SECRET }); expect(config.maxNumber).toBe(7000); }); @@ -46,11 +48,27 @@ describe("parseApiConfig", () => { expect(() => parseApiConfig({})).toThrow("SECRET is required"); }); + test("rejects short secret", () => { + expect(() => parseApiConfig({ SECRET: "short" })).toThrow("SECRET must be at least 32 characters"); + }); + test("rejects invalid numbers", () => { - expect(() => parseApiConfig({ PORT: "abc", SECRET: "configured-secret" })).toThrow("PORT must be a positive integer"); - expect(() => parseApiConfig({ EXPIREMINUTES: "0", SECRET: "configured-secret" })).toThrow("EXPIREMINUTES must be a positive integer"); - expect(() => parseApiConfig({ MAXRECORDS: "-1", SECRET: "configured-secret" })).toThrow("MAXRECORDS must be a positive integer"); - expect(() => parseApiConfig({ MAXNUMBER: "1.5", SECRET: "configured-secret" })).toThrow("MAXNUMBER must be a positive integer"); + expect(() => parseApiConfig({ PORT: "abc", SECRET: LONG_SECRET })).toThrow("PORT must be a positive integer"); + expect(() => parseApiConfig({ EXPIREMINUTES: "0", SECRET: LONG_SECRET })).toThrow("EXPIREMINUTES must be a positive integer"); + expect(() => parseApiConfig({ MAXRECORDS: "-1", SECRET: LONG_SECRET })).toThrow("MAXRECORDS must be a positive integer"); + expect(() => parseApiConfig({ MAXNUMBER: "1.5", SECRET: LONG_SECRET })).toThrow("MAXNUMBER must be a positive integer"); + }); + + test("rejects unsupported algorithm", () => { + expect(() => parseApiConfig({ SECRET: LONG_SECRET, ALGORITHM: "SHA-256" })).toThrow( + "ALGORITHM must be one of: PBKDF2/SHA-256, PBKDF2/SHA-384, PBKDF2/SHA-512" + ); + }); + + test("accepts all supported algorithms", () => { + expect(parseApiConfig({ SECRET: LONG_SECRET, ALGORITHM: "PBKDF2/SHA-256" }).algorithm).toBe("PBKDF2/SHA-256"); + expect(parseApiConfig({ SECRET: LONG_SECRET, ALGORITHM: "PBKDF2/SHA-384" }).algorithm).toBe("PBKDF2/SHA-384"); + expect(parseApiConfig({ SECRET: LONG_SECRET, ALGORITHM: "PBKDF2/SHA-512" }).algorithm).toBe("PBKDF2/SHA-512"); }); }); diff --git a/src/config.ts b/src/config.ts index fdb12bb..6ff9cc0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,9 @@ export type CorsOrigin = "*" | string[]; +export type SupportedAlgorithm = "PBKDF2/SHA-256" | "PBKDF2/SHA-384" | "PBKDF2/SHA-512"; + export type ApiConfig = { - algorithm: string; + algorithm: SupportedAlgorithm; corsOrigin: CorsOrigin; expireMinutes: number; hmacKey: string; @@ -46,14 +48,29 @@ const parseUrl = (value: string, name: string): string => { } }; +const SUPPORTED_ALGORITHMS: SupportedAlgorithm[] = [ + "PBKDF2/SHA-256", + "PBKDF2/SHA-384", + "PBKDF2/SHA-512", +]; + +const parseAlgorithm = (value: string | undefined): SupportedAlgorithm => { + const algorithm = (value || "PBKDF2/SHA-256").trim() as SupportedAlgorithm; + if (!SUPPORTED_ALGORITHMS.includes(algorithm)) { + throw new Error(`ALGORITHM must be one of: ${SUPPORTED_ALGORITHMS.join(", ")}`); + } + return algorithm; +}; + export const parseApiConfig = (env: Env = process.env): ApiConfig => { const hmacKey = env.SECRET?.trim(); if (!hmacKey) throw new Error("SECRET is required"); + if (hmacKey.length < 32) throw new Error("SECRET must be at least 32 characters"); const maxNumberSource = env.MAXNUMBER === undefined || env.MAXNUMBER.trim() === "" ? "COST" : "MAXNUMBER"; return { - algorithm: env.ALGORITHM?.trim() || "PBKDF2/SHA-256", + algorithm: parseAlgorithm(env.ALGORITHM), corsOrigin: parseCorsOrigin(env.CORS_ORIGIN), expireMinutes: parsePositiveInteger(env, "EXPIREMINUTES", 10), hmacKey,