Skip to content

Commit 0d5ea3e

Browse files
Arthur Freitas RamosCopilot
authored andcommitted
fix(web): harden runtime and onboarding UX
Add nonce-based CSP, UUID job identifiers, safe artifact cleanup, and resilient API/SSE handling. Improve first-run guidance, accessibility, responsive behavior, and regression coverage while updating vulnerable transitive dependencies. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 74c77a7 commit 0d5ea3e

9 files changed

Lines changed: 468 additions & 134 deletions

File tree

package-lock.json

Lines changed: 38 additions & 27 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/web/routes.ts

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import type { Application, Request, Response } from "express";
22
import { EventEmitter } from "events";
33
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
4-
import { mkdir, readFile } from "fs/promises";
5-
import { join, resolve } from "path";
4+
import { randomUUID } from "node:crypto";
5+
import { mkdir, readFile, rm } from "fs/promises";
6+
import { basename, dirname, join, resolve } from "path";
67

78
import { applyOutputFormat, type OutputFormat } from "../formatter.js";
89
import { parseGitHubUrl } from "../ingest.js";
@@ -55,20 +56,54 @@ const MAX_JOBS = 100; // Prevent unbounded memory growth
5556

5657
let pruneTimer: NodeJS.Timeout | null = null;
5758

58-
function pruneExpiredJobs(): void {
59-
const now = Date.now();
59+
async function removeJob(id: string, job: AnalysisJob): Promise<void> {
60+
jobs.delete(id);
61+
62+
if (!job.result?.outputDir) {
63+
return;
64+
}
65+
66+
const outputRoot = resolve(process.cwd(), ".bootcamp-output");
67+
const jobRoot = resolve(dirname(job.result.outputDir));
68+
if (
69+
jobRoot === outputRoot ||
70+
!isPathInsideDir(outputRoot, jobRoot) ||
71+
basename(jobRoot) !== job.id
72+
) {
73+
console.error(`[web] Refusing to remove unexpected job output path for ${job.id}`);
74+
return;
75+
}
76+
77+
try {
78+
await rm(jobRoot, { recursive: true, force: true });
79+
} catch (error: unknown) {
80+
console.error(
81+
`[web] Failed to remove output for expired job ${job.id}: ${getErrorMessage(error, "Unknown cleanup error")}`
82+
);
83+
}
84+
}
85+
86+
export async function pruneExpiredJobs(now: number = Date.now()): Promise<void> {
87+
const removals: Promise<void>[] = [];
6088
for (const [id, job] of jobs) {
6189
if (job.completedAt && now - job.completedAt > JOB_TTL_MS) {
62-
jobs.delete(id);
90+
removals.push(removeJob(id, job));
6391
}
6492
}
93+
await Promise.all(removals);
6594
}
6695

6796
export function startJobPruner(): void {
6897
if (pruneTimer) {
6998
return;
7099
}
71-
pruneTimer = setInterval(pruneExpiredJobs, JOB_TTL_MS);
100+
pruneTimer = setInterval(() => {
101+
void pruneExpiredJobs().catch((error: unknown) => {
102+
console.error(
103+
`[web] Failed to prune expired jobs: ${getErrorMessage(error, "Unknown cleanup error")}`
104+
);
105+
});
106+
}, JOB_TTL_MS);
72107
pruneTimer.unref();
73108
}
74109

@@ -84,7 +119,7 @@ export function stopJobPruner(): void {
84119
* Generate unique job ID
85120
*/
86121
function generateJobId(): string {
87-
return `job_${Date.now()}_${Math.random().toString(36).substring(7)}`;
122+
return `job_${randomUUID()}`;
88123
}
89124

90125
// Client-supplied analysis options are validated/allowlisted here rather than
@@ -313,7 +348,7 @@ async function runAnalysis(job: AnalysisJob, options: Record<string, unknown>):
313348
}
314349

315350
export function registerRoutes(app: Application): void {
316-
const limiterKeyPrefix = `web-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
351+
const limiterKeyPrefix = `web-${randomUUID()}`;
317352
const analysisEndpointRateLimit = rateLimit({
318353
windowMs: 15 * 60 * 1000,
319354
limit: 5,
@@ -360,8 +395,10 @@ export function registerRoutes(app: Application): void {
360395

361396
try {
362397
parseGitHubUrl(repoUrl); // Validate URL
363-
} catch (error: unknown) {
364-
res.status(400).json({ error: getErrorMessage(error, "Invalid repository URL") });
398+
} catch {
399+
res.status(400).json({
400+
error: "Enter a public GitHub, GitLab, or Bitbucket repository.",
401+
});
365402
return;
366403
}
367404

@@ -384,7 +421,10 @@ export function registerRoutes(app: Application): void {
384421
}
385422
}
386423
if (oldestCompletedId) {
387-
jobs.delete(oldestCompletedId);
424+
const oldestCompletedJob = jobs.get(oldestCompletedId);
425+
if (oldestCompletedJob) {
426+
await removeJob(oldestCompletedId, oldestCompletedJob);
427+
}
388428
} else {
389429
// All jobs are still running — reject to prevent OOM
390430
res.status(503).json({ error: "Server at capacity, try again later" });
@@ -504,10 +544,7 @@ export function registerRoutes(app: Application): void {
504544
res.setHeader("Content-Type", "text/plain; charset=utf-8");
505545
res.send(content);
506546
} catch (error: unknown) {
507-
console.error(
508-
`[web] Failed to read generated file "${filename}" for job ${jobId}:`,
509-
error
510-
);
547+
console.error(`[web] Failed to read generated file "${filename}" for job ${jobId}:`, error);
511548
res.status(500).json({ error: "Failed to read generated file" });
512549
}
513550
}

src/web/server.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import express, { Request, Response } from "express";
77
import chalk from "chalk";
88
import helmet from "helmet";
9+
import { randomBytes } from "node:crypto";
10+
import type { ServerResponse } from "node:http";
911

1012
import { getIndexHtml } from "./templates.js";
1113
import { registerRoutes, startJobPruner, stopJobPruner } from "./routes.js";
@@ -17,18 +19,40 @@ const DEFAULT_PORT = 3000;
1719
// `--host` override can be wired through this parameter for opt-in exposure.
1820
const DEFAULT_HOST = "127.0.0.1";
1921

22+
function getNonceDirective(res: ServerResponse): string {
23+
if (!("locals" in res)) {
24+
throw new Error("CSP nonce is unavailable");
25+
}
26+
const locals = res.locals;
27+
if (
28+
typeof locals !== "object" ||
29+
locals === null ||
30+
!("cspNonce" in locals) ||
31+
typeof locals.cspNonce !== "string"
32+
) {
33+
throw new Error("CSP nonce is unavailable");
34+
}
35+
return `'nonce-${locals.cspNonce}'`;
36+
}
37+
2038
/**
2139
* Create Express app
2240
*/
2341
export function createApp(): express.Application {
2442
const app = express();
43+
app.use((_req, res, next) => {
44+
res.locals.cspNonce = randomBytes(32).toString("base64");
45+
next();
46+
});
2547
app.use(
2648
helmet({
2749
contentSecurityPolicy: {
2850
directives: {
2951
defaultSrc: ["'self'"],
30-
scriptSrc: ["'self'", "'unsafe-inline'"],
31-
styleSrc: ["'self'", "'unsafe-inline'"],
52+
scriptSrc: ["'self'", (_req, res) => getNonceDirective(res)],
53+
scriptSrcAttr: ["'none'"],
54+
styleSrc: ["'self'", (_req, res) => getNonceDirective(res)],
55+
styleSrcAttr: ["'none'"],
3256
imgSrc: ["'self'", "data:"],
3357
connectSrc: ["'self'"],
3458
},
@@ -59,7 +83,8 @@ export function createApp(): express.Application {
5983

6084
// Serve static HTML
6185
app.get("/", (req: Request, res: Response) => {
62-
res.send(getIndexHtml());
86+
res.setHeader("Cache-Control", "no-store");
87+
res.send(getIndexHtml(res.locals.cspNonce));
6388
});
6489

6590
registerRoutes(app);

0 commit comments

Comments
 (0)