Skip to content

Commit caaf26b

Browse files
committed
Protect loopback review sessions
1 parent f65050c commit caaf26b

15 files changed

Lines changed: 850 additions & 280 deletions

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,10 @@ npm run diffsplain -- doctor
6868
npm run doctor
6969
```
7070

71-
Run the checks:
71+
Install Chromium once, then run the checks:
7272

7373
```sh
74+
npm run test:browser:install
7475
npm run lint
7576
npm test
7677
```

‎app/page.tsx‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -236,11 +236,17 @@ export default function Home() {
236236
const latestVersion = useRef<string | null>(null);
237237
const touchStart = useRef<number | null>(null);
238238
const searchRef = useRef<HTMLInputElement | null>(null);
239+
const session = useMemo(
240+
() => new URLSearchParams(window.location.hash.slice(1)),
241+
[],
242+
);
243+
const [access, setAccess] = useState(() => session.get("access"));
239244

240245
const refresh = useCallback(async () => {
241246
try {
242247
const liveUrl = new URL("diff-data.json", document.baseURI);
243248
liveUrl.searchParams.set("t", String(Date.now()));
249+
if (access) liveUrl.searchParams.set("access", access);
244250
const liveResponse = await fetch(liveUrl, {
245251
cache: "no-store",
246252
});
@@ -268,7 +274,7 @@ export default function Home() {
268274
error instanceof Error ? error.message : "Could not read the snapshot",
269275
);
270276
}
271-
}, []);
277+
}, [access]);
272278

273279
useEffect(() => {
274280
const initial = window.setTimeout(() => void refresh(), 0);
@@ -287,10 +293,9 @@ export default function Home() {
287293
};
288294
if ("EventSource" in window) {
289295
const eventsUrl = new URL("events", document.baseURI);
290-
const project = new URLSearchParams(window.location.hash.slice(1)).get(
291-
"project",
292-
);
296+
const project = session.get("project");
293297
if (project) eventsUrl.searchParams.set("project", project);
298+
if (access) eventsUrl.searchParams.set("access", access);
294299
events = new EventSource(eventsUrl);
295300
events.addEventListener("ready", () => {
296301
stopPolling();
@@ -299,6 +304,13 @@ export default function Home() {
299304
void refresh();
300305
});
301306
events.addEventListener("update", () => void refresh());
307+
events.addEventListener("access", (event) => {
308+
const nextAccess = (event as MessageEvent<string>).data;
309+
if (!/^[A-Za-z0-9_-]{32,}$/.test(nextAccess)) return;
310+
session.set("access", nextAccess);
311+
window.history.replaceState(null, "", `#${session}`);
312+
setAccess(nextAccess);
313+
});
302314
events.addEventListener("error", startPolling);
303315
} else {
304316
startPolling();
@@ -310,7 +322,7 @@ export default function Home() {
310322
events?.close();
311323
window.clearInterval(ticker);
312324
};
313-
}, [refresh]);
325+
}, [access, refresh, session]);
314326

315327
const files = useMemo(() => snapshot?.files ?? [], [snapshot]);
316328
const currentIndex = Math.max(

‎benchmarks/live-update-speed.mjs‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,15 @@ function stop(child) {
6161
});
6262
}
6363

64+
function protectedUrl(reviewUrl, path) {
65+
const route = new URL(path, reviewUrl);
66+
const access = new URLSearchParams(new URL(reviewUrl).hash.slice(1)).get(
67+
"access",
68+
);
69+
if (access) route.searchParams.set("access", access);
70+
return route;
71+
}
72+
6473
const temporary = mkdtempSync(join(tmpdir(), "diffsplain-updates-"));
6574
const output = join(temporary, "diff-data.json");
6675
writeFileSync(output, JSON.stringify({ version: "0" }));
@@ -82,7 +91,10 @@ try {
8291
const url = await waitForUrl(child);
8392
const samples = [];
8493
if (mode === "events") {
85-
const response = await fetch(`${url}/events`);
94+
const response = await fetch(protectedUrl(url, "events"));
95+
if (!response.ok) {
96+
throw new Error(`Event stream returned ${response.status}`);
97+
}
8698
reader = response.body.getReader();
8799
await reader.read();
88100
for (let version = 1; version <= 9; version += 1) {
@@ -96,7 +108,7 @@ try {
96108
let seenVersion = "0";
97109
const waiters = new Map();
98110
poll = setInterval(async () => {
99-
const response = await fetch(`${url}/diff-data.json`, {
111+
const response = await fetch(protectedUrl(url, "diff-data.json"), {
100112
cache: "no-store",
101113
});
102114
const value = await response.json();

‎package-lock.json‎

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

‎package.json‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
},
2828
"files": [
2929
"dist",
30+
"scripts/access-token.mjs",
3031
"scripts/build-diff-data.mjs",
3132
"scripts/cli-args.mjs",
3233
"scripts/coding-agents.mjs",
@@ -56,10 +57,13 @@
5657
"build": "vite build && node scripts/clean-built-live-data.mjs",
5758
"start": "node scripts/serve-built.mjs",
5859
"prepack": "npm run build",
59-
"test": "npm run build && node --test tests/*.test.mjs",
60+
"test": "npm run build && node --test tests/*.test.mjs && npm run test:browser",
61+
"test:browser": "node --test tests/browser/*.test.mjs",
62+
"test:browser:install": "playwright install chromium",
6063
"lint": "tsc --noEmit && eslint ."
6164
},
6265
"devDependencies": {
66+
"@playwright/test": "1.62.0",
6367
"@tailwindcss/postcss": "4.2.1",
6468
"@types/node": "22.19.19",
6569
"@types/react": "19.2.14",

‎scripts/access-token.mjs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { createHash } from 'node:crypto';
2+
import { tmpdir, userInfo } from 'node:os';
3+
import { join } from 'node:path';
4+
5+
export function accessTokenDirectory({
6+
temporaryDirectory = tmpdir(),
7+
identity = userInfo(),
8+
} = {}) {
9+
const userKey = createHash('sha256')
10+
.update(
11+
JSON.stringify([
12+
identity.uid,
13+
identity.gid,
14+
identity.username,
15+
identity.homedir,
16+
]),
17+
)
18+
.digest('hex')
19+
.slice(0, 16);
20+
return join(temporaryDirectory, `diffsplain-access-${userKey}`);
21+
}

‎scripts/cli-args.mjs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,7 @@ export function parseCliArgs(
315315
if (!/^\d+$/.test(portValue) || Number(portValue) > 65_535) {
316316
fail('--port must be a number from 0 to 65535');
317317
}
318+
const host = (options.get('--host') || 'localhost').replace(/^\[|\]$/g, '');
318319

319320
return {
320321
help: false,
@@ -326,7 +327,7 @@ export function parseCliArgs(
326327
agentArgs,
327328
port: Number(portValue),
328329
portWasPassed: options.has('--port'),
329-
host: options.get('--host') || 'localhost',
330+
host,
330331
browserEnabled: !options.has('--no-browser'),
331332
forceSummaryRegeneration: options.has('--force'),
332333
};

‎scripts/present.mjs‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
#!/usr/bin/env node
22

33
import { spawn, spawnSync } from 'node:child_process';
4-
import { createHash } from 'node:crypto';
4+
import { createHash, randomBytes } from 'node:crypto';
55
import {
6+
chmodSync,
67
existsSync,
8+
mkdirSync,
79
mkdtempSync,
810
readFileSync,
911
rmSync,
12+
writeFileSync,
1013
} from 'node:fs';
1114
import { tmpdir } from 'node:os';
1215
import { dirname, join, resolve } from 'node:path';
@@ -19,6 +22,7 @@ import {
1922
selectCodingAgent,
2023
} from './coding-agents.mjs';
2124
import { doctorReport } from './doctor.mjs';
25+
import { accessTokenDirectory } from './access-token.mjs';
2226

2327
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
2428
const callerDirectory = process.cwd();
@@ -95,6 +99,20 @@ const projectKey = createHash('sha256')
9599
)
96100
.digest('hex')
97101
.slice(0, 12);
102+
const accessDirectory = accessTokenDirectory();
103+
const accessPath = join(accessDirectory, `${projectKey}.token`);
104+
mkdirSync(accessDirectory, { recursive: true, mode: 0o700 });
105+
chmodSync(accessDirectory, 0o700);
106+
let previousAccess;
107+
try {
108+
const savedAccess = readFileSync(accessPath, 'utf8').trim();
109+
if (/^[A-Za-z0-9_-]{43}$/.test(savedAccess)) previousAccess = savedAccess;
110+
} catch {
111+
// The first run for a project has no prior tab access value.
112+
}
113+
const access = randomBytes(32).toString('base64url');
114+
writeFileSync(accessPath, access, { mode: 0o600 });
115+
chmodSync(accessPath, 0o600);
98116
if (agentEnabled) {
99117
feedArgs.push('--ignore-summary-watch');
100118
agentArgs.push('--snapshot', outputPath);
@@ -172,6 +190,9 @@ function startSite() {
172190
host,
173191
'--project',
174192
projectKey,
193+
'--access',
194+
access,
195+
...(previousAccess ? ['--previous-access', previousAccess] : []),
175196
...(!cli.portWasPassed ? ['--increment-port'] : []),
176197
],
177198
{ cwd: root, stdio: ['ignore', 'pipe', 'inherit'] },

0 commit comments

Comments
 (0)