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
2 changes: 1 addition & 1 deletion src/__tests__/admin-response-shape.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function buildApp(): Express {
return app;
}

const authHeader = { Authorization: "Bearer test-key" };
const authHeader = { Authorization: "Bearer test-key", "x-request-timestamp": Date.now().toString() };

describe("admin /update-scores response shape", () => {
let app: Express;
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/admin-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ function buildApp(): Express {
}

const ADMIN_API_KEY = "test-key";
const authHeader = { Authorization: `Bearer ${ADMIN_API_KEY}` };
const authHeader = { Authorization: `Bearer ${ADMIN_API_KEY}`, "x-request-timestamp": Date.now().toString() };

describe("admin /update-scores input validation", () => {
let app: Express;
Expand Down
4 changes: 3 additions & 1 deletion src/__tests__/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ function buildApp(): Express {
return app;
}

const AUTH_HEADER = { Authorization: "Bearer test-key" };
const AUTH_HEADER = { Authorization: "Bearer test-key", "x-request-timestamp": Date.now().toString() };


describe("admin routes", () => {
let app: Express;
Expand Down Expand Up @@ -146,6 +147,7 @@ describe("admin routes", () => {
const res = await request(app)
.post("/api/admin/update-scores")
.set("Authorization", "Bearer test-key ")
.set("x-request-timestamp", Date.now().toString())
.send({});
expect(res.status).toBe(200);
});
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jest.mock("../config", () => {
});

const ADMIN_API_KEY = "test-key";
const authHeader = { Authorization: `Bearer ${ADMIN_API_KEY}` };
const authHeader = { Authorization: `Bearer ${ADMIN_API_KEY}`, "x-request-timestamp": Date.now().toString()};

function buildApp(): Express {
const app = express();
Expand Down
4 changes: 3 additions & 1 deletion src/__tests__/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ describe("Security - injection attacks", () => {
const res = await request(app)
.post("/api/admin/update-scores")
.set("Authorization", "Bearer test-key")
.set("x-request-timestamp", Date.now().toString())
.send(JSON.parse('{"__proto__": {"project_ids": [999]}}'))
.expect(200);

Expand All @@ -66,11 +67,12 @@ describe("Security - injection attacks", () => {
const res = await request(app)
.post("/api/admin/update-scores")
.set("Authorization", "Bearer test-key")
.set("x-request-timestamp", Date.now().toString())
.send({ constructor: { prototype: { project_ids: [999] } } })
.expect(200);

expect(res.body.updated).toBe(2);
expect(registry.getTotalProjects).toHaveBeenCalled();
});
});
});
});
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export const config = {
PORT: numEnv("PORT", 3001),
FRONTEND_URL: optionalEnv("FRONTEND_URL", "http://localhost:3000"),
ADMIN_API_KEY: process.env.ADMIN_API_KEY || "",
ADMIN_REQUEST_MAX_AGE_MS: numEnv("ADMIN_REQUEST_MAX_AGE_MS", 300000),
INITIAL_ADMIN_USER_ID: process.env.INITIAL_ADMIN_USER_ID || "",
WS_AUTH_TOKEN: process.env.WS_AUTH_TOKEN || "",

/** Database connection */
Expand Down
24 changes: 24 additions & 0 deletions src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,30 @@ router.use((req: Request, res: Response, next: NextFunction) => {
next();
});

// Timestamp expiration validation (Issue #545)
router.use((req: Request, res: Response, next: NextFunction) => {
const timestampHeader = req.header("x-request-timestamp");
if (!timestampHeader) {
logger.warn("[admin] Missing X-Request-Timestamp header");
return res.status(401).json(errorBody("unauthorized", "Missing X-Request-Timestamp header"));
}

const clientTime = parseInt(timestampHeader, 10);
if (isNaN(clientTime)) {
logger.warn("[admin] Invalid X-Request-Timestamp header format");
return res.status(401).json(errorBody("unauthorized", "Invalid timestamp format"));
}

const ageMs = Math.abs(Date.now() - clientTime);
if (ageMs > config.ADMIN_REQUEST_MAX_AGE_MS) {
logger.warn(`[admin] Request expired. Age: ${ageMs}ms, Max allowed: ${config.ADMIN_REQUEST_MAX_AGE_MS}ms`);
return res.status(401).json(errorBody("unauthorized", "Request expired"));
}

next();
});


/** A per-project score update that made it onto the ledger (or was deferred). */
type ScoreUpdateResult = {
project_id: number;
Expand Down
Loading