Skip to content

Commit 3da7c7c

Browse files
Sage HartSage Hart
authored andcommitted
fix(security): require bearer auth on dashboard approval API
Gate /api/approvals routes with DASHBOARD_API_TOKEN when configured.
1 parent dc57ffe commit 3da7c7c

2 files changed

Lines changed: 45 additions & 1 deletion

File tree

src/api/auth.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import express, { type Request, type Response, type NextFunction } from "express";
2+
3+
/**
4+
* Bearer-token gate for dashboard mutating API routes.
5+
* Set DASHBOARD_API_TOKEN in the environment; when unset, auth is disabled (dev only).
6+
*/
7+
export function requireDashboardAuth(req: Request, res: Response, next: NextFunction): void {
8+
const expected = process.env.DASHBOARD_API_TOKEN?.trim();
9+
if (!expected) {
10+
next();
11+
return;
12+
}
13+
const header = req.headers.authorization ?? "";
14+
const prefix = "Bearer ";
15+
if (!header.startsWith(prefix)) {
16+
res.status(401).json({ error: "dashboard authentication required" });
17+
return;
18+
}
19+
const token = header.slice(prefix.length).trim();
20+
if (token.length !== expected.length || !timingSafeEqual(token, expected)) {
21+
res.status(401).json({ error: "dashboard authentication required" });
22+
return;
23+
}
24+
next();
25+
}
26+
27+
function timingSafeEqual(a: string, b: string): boolean {
28+
if (a.length !== b.length) return false;
29+
let mismatch = 0;
30+
for (let i = 0; i < a.length; i++) {
31+
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
32+
}
33+
return mismatch === 0;
34+
}
35+
36+
export function installDashboardAuth(app: express.Application): void {
37+
app.use("/api/approvals", requireDashboardAuth);
38+
app.post("/api/approvals/:id/decide", requireDashboardAuth);
39+
}

src/api/server.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import express from "express";
66
import { WebSocketServer, WebSocket } from "ws";
77

88
import { newTask, type CompanyServices, type TaskState } from "../core";
9+
import { installDashboardAuth } from "./auth";
910

1011
const __dirname = dirname(fileURLToPath(import.meta.url));
1112
const PUBLIC_DIR = resolve(__dirname, "../dashboard/public");
@@ -19,6 +20,7 @@ export interface HttpServerHandles {
1920
export function createHttpServer(services: CompanyServices): HttpServerHandles {
2021
const app = express();
2122
app.use(express.json());
23+
installDashboardAuth(app);
2224

2325
// Suppress the browser's default favicon request (no asset shipped).
2426
app.get("/favicon.ico", (_req, res) => {
@@ -83,11 +85,14 @@ export function createHttpServer(services: CompanyServices): HttpServerHandles {
8385
const id = req.params.id;
8486
const body = req.body ?? {};
8587
const decision = body.decision;
86-
const by = typeof body.by === "string" ? body.by : "human";
88+
let by = typeof body.by === "string" ? body.by : "human";
8789
if (decision !== "approved" && decision !== "rejected") {
8890
res.status(400).json({ error: "decision must be 'approved' or 'rejected'" });
8991
return;
9092
}
93+
if (process.env.DASHBOARD_API_TOKEN?.trim()) {
94+
by = "dashboard-operator";
95+
}
9196
const approval = services.policy.decide(id, decision, by);
9297
if (!approval) {
9398
res.status(404).json({ error: "approval not found" });

0 commit comments

Comments
 (0)