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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ jobs:

- name: Install bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.x"

- name: Install dependencies
run: bun install --frozen-lockfile
Expand All @@ -36,6 +38,8 @@ jobs:

- name: Install bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.x"

- name: Install dependencies
run: bun install --frozen-lockfile
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ jobs:

- name: Install bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.x"

- name: Install dependencies
run: bun install --frozen-lockfile
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/security-audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ jobs:

- name: Install bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.x"

- name: Install dependencies
run: bun install --frozen-lockfile
Expand Down Expand Up @@ -99,6 +101,8 @@ jobs:

- name: Install bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.x"

- name: Install dependencies
run: bun install --frozen-lockfile
Expand Down
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
20
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
"name": "heliobond-backend",
"version": "1.0.0",
"private": true,
"engines": {
"node": ">=20.0.0",
"bun": ">=1.0.0"
},
"scripts": {
"dev": "ts-node src/index.ts",
"build": "tsc",
Expand Down
35 changes: 33 additions & 2 deletions src/__tests__/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,11 @@ describe("admin routes", () => {
});

it("documents current behavior for a token with trailing whitespace", async () => {
// The middleware itself does a strict `!==` comparison with no
// The middleware itself does an exact constant-time comparison with no
// trimming. However, HTTP header values are trimmed of leading/
// trailing whitespace by the underlying HTTP parser before the
// handler ever sees them (per RFC 7230), so in practice a trailing
// space on the wire does NOT survive to reach the `!==` check — the
// space on the wire does NOT survive to reach the comparison — the
// request passes through. This test pins down that actual observed
// behavior; it is not asserting this is the desired security posture.
const res = await request(app)
Expand All @@ -155,6 +155,37 @@ describe("admin routes", () => {
expect(res.status).toBe(401);
expect(res.body.error.code).toBe("unauthorized");
});

// ── Constant-time comparison (#209) ────────────────────────────────────
// The comparison is timing-safe, so near-miss tokens must be rejected the
// same way as completely wrong ones — no early exit on the first mismatch.

it("rejects a token that differs only in the last character", async () => {
const res = await request(app)
.post("/api/admin/update-scores")
.set("Authorization", "Bearer test-keZ")
.send({});
expect(res.status).toBe(401);
expect(res.body.error.code).toBe("unauthorized");
});

it("rejects a token that is a prefix of the real key", async () => {
const res = await request(app)
.post("/api/admin/update-scores")
.set("Authorization", "Bearer test-k")
.send({});
expect(res.status).toBe(401);
expect(res.body.error.code).toBe("unauthorized");
});

it("rejects a token that extends the real key", async () => {
const res = await request(app)
.post("/api/admin/update-scores")
.set("Authorization", "Bearer test-key-extra")
.send({});
expect(res.status).toBe(401);
expect(res.body.error.code).toBe("unauthorized");
});
});

// ── POST /update-scores ──────────────────────────────────────────────────
Expand Down
68 changes: 68 additions & 0 deletions src/__tests__/listen-errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describeListenError, handleListenError } from "../lib/listen-errors";

function errno(code: string, message = "listen error"): NodeJS.ErrnoException {
const err = new Error(message) as NodeJS.ErrnoException;
err.code = code;
return err;
}

describe("listen error handling (#206)", () => {
describe("describeListenError", () => {
it("explains EADDRINUSE with the conflicting port", () => {
expect(describeListenError(errno("EADDRINUSE"), 3001)).toBe(
"Port 3001 is already in use. Stop the process using it or set PORT to a free port.",
);
});

it("explains EACCES as a privilege problem", () => {
const message = describeListenError(errno("EACCES"), 80);
expect(message).toContain("Port 80");
expect(message).toContain("elevated privileges");
});

it("explains EADDRNOTAVAIL", () => {
expect(describeListenError(errno("EADDRNOTAVAIL"), 3001)).toContain("not available");
});

it("falls back to the underlying message for other listen errors", () => {
const message = describeListenError(errno("EPERM", "operation not permitted"), 3001);
expect(message).toBe("Failed to bind to port 3001: operation not permitted");
});

it("handles errors without a code", () => {
const err = new Error("boom") as NodeJS.ErrnoException;
expect(describeListenError(err, 3001)).toBe("Failed to bind to port 3001: boom");
});
});

describe("handleListenError", () => {
let errorSpy: jest.SpyInstance;

beforeEach(() => {
errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
});

afterEach(() => {
errorSpy.mockRestore();
});

it("exits with status code 1 on a bind failure", () => {
const exit = jest.fn();
handleListenError(errno("EADDRINUSE"), 3001, exit);
expect(exit).toHaveBeenCalledWith(1);
});

it("logs a clear port-conflict message", () => {
handleListenError(errno("EADDRINUSE"), 3001, jest.fn());
const logged = errorSpy.mock.calls[0][0] as string;
expect(logged).toContain("Port 3001 is already in use");
expect(JSON.parse(logged)).toMatchObject({ level: "error", error_code: "EADDRINUSE" });
});

it("exits on non-EADDRINUSE listen errors too", () => {
const exit = jest.fn();
handleListenError(errno("EACCES"), 80, exit);
expect(exit).toHaveBeenCalledWith(1);
});
});
});
53 changes: 53 additions & 0 deletions src/__tests__/runtime-version-pinning.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import * as fs from "fs";
import * as path from "path";

const ROOT = path.resolve(__dirname, "../../");

function read(relativePath: string): string {
return fs.readFileSync(path.join(ROOT, relativePath), "utf8");
}

describe("runtime version pinning (#207)", () => {
describe("package.json engines", () => {
const pkg = JSON.parse(read("package.json")) as {
engines?: { node?: string; bun?: string };
};

it("declares an engines.node range", () => {
expect(pkg.engines?.node).toBeDefined();
});

it("requires Node.js 20 or newer", () => {
expect(pkg.engines?.node).toBe(">=20.0.0");
});

it("declares a bun range for the bun-based scripts", () => {
expect(pkg.engines?.bun).toMatch(/^>=1\./);
});
});

describe(".nvmrc", () => {
it("exists", () => {
expect(fs.existsSync(path.join(ROOT, ".nvmrc"))).toBe(true);
});

it("pins Node.js 20, matching engines and the Dockerfile", () => {
expect(read(".nvmrc").trim()).toBe("20");
expect(read("Dockerfile")).toContain("node:20-alpine");
});
});

describe("CI workflows", () => {
const workflows = ["ci.yml", "release.yml", "security-audit.yml"];

it.each(workflows)("%s pins a bun-version for every setup-bun step", (workflow) => {
const content = read(path.join(".github", "workflows", workflow));
const setupSteps = content.match(/uses: oven-sh\/setup-bun@v\d+[\s\S]*?(?=\n\n|\n {6}- |$)/g);

expect(setupSteps).not.toBeNull();
for (const step of setupSteps ?? []) {
expect(step).toMatch(/bun-version:\s*"1\.x"/);
}
});
});
});
12 changes: 6 additions & 6 deletions src/__tests__/securityHeaders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ describe("securityHeaders middleware", () => {
expect(res.headers["x-content-type-options"]).toBe("nosniff");
});

it("sets X-Frame-Options: SAMEORIGIN", async () => {
it("sets X-Frame-Options: DENY", async () => {
const res = await request(app).get("/test");
expect(res.headers["x-frame-options"]).toBe("SAMEORIGIN");
expect(res.headers["x-frame-options"]).toBe("DENY");
});

it("sets X-XSS-Protection header", async () => {
Expand All @@ -44,20 +44,20 @@ describe("securityHeaders middleware", () => {

const jsonRes = await request(app).get("/json");
expect(jsonRes.headers["x-content-type-options"]).toBe("nosniff");
expect(jsonRes.headers["x-frame-options"]).toBe("SAMEORIGIN");
expect(jsonRes.headers["x-frame-options"]).toBe("DENY");

const textRes = await request(app).get("/text");
expect(textRes.headers["x-content-type-options"]).toBe("nosniff");
expect(textRes.headers["x-frame-options"]).toBe("SAMEORIGIN");
expect(textRes.headers["x-frame-options"]).toBe("DENY");

const postRes = await request(app).post("/post");
expect(postRes.headers["x-content-type-options"]).toBe("nosniff");
expect(postRes.headers["x-frame-options"]).toBe("SAMEORIGIN");
expect(postRes.headers["x-frame-options"]).toBe("DENY");
});

it("sets all expected security headers on every request", async () => {
const res = await request(app).get("/test");

const expectedHeaders = [
"x-content-type-options",
"x-frame-options",
Expand Down
72 changes: 72 additions & 0 deletions src/__tests__/timing-safe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// `crypto`'s exports are non-configurable, so the constant-time assertions below
// wrap timingSafeEqual through a module mock that still runs the real function.
const mockTimingSafeEqual = jest.fn();

jest.mock("crypto", () => {
const actual = jest.requireActual<typeof import("crypto")>("crypto");
return {
...actual,
timingSafeEqual: (a: NodeJS.ArrayBufferView, b: NodeJS.ArrayBufferView) => {
mockTimingSafeEqual(a, b);
return actual.timingSafeEqual(a, b);
},
};
});

import { timingSafeCompare } from "../lib/timing-safe";

describe("timingSafeCompare (#209)", () => {
it("returns true for identical strings", () => {
expect(timingSafeCompare("Bearer secret-key", "Bearer secret-key")).toBe(true);
});

it("returns false when a single character differs", () => {
expect(timingSafeCompare("Bearer secret-key", "Bearer secret-keY")).toBe(false);
});

it("returns false for different lengths without throwing", () => {
expect(timingSafeCompare("short", "a-much-longer-value")).toBe(false);
expect(timingSafeCompare("a-much-longer-value", "short")).toBe(false);
});

it("handles empty strings", () => {
expect(timingSafeCompare("", "")).toBe(true);
expect(timingSafeCompare("", "Bearer key")).toBe(false);
});

it("is case sensitive", () => {
expect(timingSafeCompare("bearer key", "Bearer key")).toBe(false);
});

it("compares multi-byte characters correctly", () => {
expect(timingSafeCompare("clé-secrète", "clé-secrète")).toBe(true);
expect(timingSafeCompare("clé-secrète", "cle-secrete")).toBe(false);
});

describe("constant-time guarantees", () => {
beforeEach(() => {
mockTimingSafeEqual.mockClear();
});

it("delegates to crypto.timingSafeEqual rather than ===", () => {
timingSafeCompare("Bearer key", "Bearer key");
expect(mockTimingSafeEqual).toHaveBeenCalledTimes(1);
});

it("never short-circuits: mismatches at any position still reach the full compare", () => {
const key = "a".repeat(64);
timingSafeCompare("b" + "a".repeat(63), key); // wrong at the first byte
timingSafeCompare("a".repeat(63) + "b", key); // wrong at the last byte
expect(mockTimingSafeEqual).toHaveBeenCalledTimes(2);
});

it("always compares equal-length buffers, so no length is leaked", () => {
timingSafeCompare("x", "a-considerably-longer-secret-value");
expect(mockTimingSafeEqual).toHaveBeenCalledTimes(1);

const [a, b] = mockTimingSafeEqual.mock.calls[0] as [Buffer, Buffer];
expect(a).toHaveLength(32);
expect(b).toHaveLength(32);
});
});
});
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import { getMigrationStatus, runMigrations, rollbackMigration } from "./lib/migr
import { featureFlagContext, registerFlagRoutes } from "./middleware/featureFlags";
import { loadFlags, getFlagAnalytics } from "./lib/feature-flags";
import { compressionMiddleware, getCompressionMetrics } from "./middleware/compression";
import { handleListenError } from "./lib/listen-errors";

const env = initEnv();

Expand Down Expand Up @@ -451,6 +452,10 @@ const server = app.listen(PORT, () => {
logger.info(`Heliobond backend listening on port ${PORT}`);
});

// Bind failures (EADDRINUSE, EACCES, …) surface here instead of as an uncaught
// exception with a raw stack trace. Exits 1 so supervisors treat it as a failure.
server.on("error", (err: NodeJS.ErrnoException) => handleListenError(err, PORT));

// Real-time score updates over WebSocket (ws://<host>/ws)
attachWebSocketServer(server);

Expand Down
Loading
Loading