Commit1d74c37
fix(nav-audit): origin-scope the PAT + reuse body in crawl mode
fix(nav-audit): origin-scope the PAT + reuse body in crawl mode
Two hardening fixes making the tool safe + fast for cross-platform use:
- SECURITY: authHeaders now only attaches the GLUECRON_PAT when the URL's
origin matches GLUECRON_HOST. In --crawl mode against another platform
(vapron.ai etc.) the Gluecron token is never sent off-origin. Verified:
a --crawl of vapron.ai sends no auth to vapron.ai.
- PERF: --crawl reuses the response body hit() already captured instead
of re-fetching every page a second time to extract links (halves the
requests, which matters on a slow site).
Cross-platform demo: `bun scripts/nav-audit.ts --crawl https://vapron.ai`
crawled 116 pages and flagged two real broken asset links —
/_build/assets/${e} and /_build/assets/${r.attrs.href} — unrendered
template literals leaking into href attributes on the live site.1 file changed+29−121d74c372b041bccbc9e14fc822392e44c86f1eb3
1 changed file+29−12
Modifiedscripts/nav-audit.ts+29−12View fileUnifiedSplit
@@ -39,10 +39,26 @@ type Row = {
3939 ms: number;
4040 ok: boolean;
4141 note: string;
42 body?: string; // captured for 2xx so --crawl can extract links without re-fetching
4243};
4344
44function authHeaders(): Record<string, string> {
45 return PAT ? { authorization: `Bearer ${PAT}` } : {};
45// SECURITY: only ever send the Gluecron PAT to the Gluecron origin. In
46// --crawl mode against another platform (vapron.ai, etc.) we must NOT leak
47// the token to a different host.
48let HOST_ORIGIN = "";
49try {
50 HOST_ORIGIN = new URL(HOST).origin;
51} catch {
52 /* HOST malformed — no auth will be attached */
53}
54function authHeaders(url: string): Record<string, string> {
55 if (!PAT) return {};
56 try {
57 if (new URL(url).origin === HOST_ORIGIN) return { authorization: `Bearer ${PAT}` };
58 } catch {
59 /* unparseable URL — send nothing */
60 }
61 return {};
4662}
4763
4864// A 200 that renders an error page still counts as broken. Sniff the title +
@@ -62,12 +78,13 @@ async function hit(group: string, label: string, path: string): Promise<Row> {
6278 const url = path.startsWith("http") ? path : HOST + path;
6379 const t0 = Date.now();
6480 try {
65 const res = await fetch(url, { headers: authHeaders(), redirect: "manual" });
81 const res = await fetch(url, { headers: authHeaders(url), redirect: "manual" });
6682 const ms = Date.now() - t0;
6783 let note = "";
6884 let ok = res.status >= 200 && res.status < 400;
85 let body: string | undefined;
6986 if (res.status >= 200 && res.status < 300) {
70 const body = await res.text();
87 body = await res.text();
7188 const eb = errorBody(body);
7289 if (eb) {
7390 ok = false;
@@ -81,7 +98,7 @@ async function hit(group: string, label: string, path: string): Promise<Row> {
8198 ok = false;
8299 note = `slow render ${ms}ms (> ${SLOW_MS}ms)`;
83100 }
84 return { group, label, url, status: res.status, ms, ok, note };
101 return { group, label, url, status: res.status, ms, ok, note, body };
85102 } catch (err) {
86103 return {
87104 group,
@@ -203,11 +220,10 @@ async function listAllRepos(): Promise<string[]> {
203220 // Best-effort: enumerate the authed user's repos, then their org repos.
204221 const out = new Set<string>();
205222 try {
206 const me = await fetch(HOST + "/api/v2/user", { headers: authHeaders() }).then((r) => r.json());
223 const me = await fetch(HOST + "/api/v2/user", { headers: authHeaders(HOST + "/api/v2/user") }).then((r) => r.json());
207224 if (me?.username) {
208 const repos = await fetch(HOST + `/api/v2/users/${encodeURIComponent(me.username)}/repos`, {
209 headers: authHeaders(),
210 }).then((r) => r.json());
225 const reposUrl = HOST + `/api/v2/users/${encodeURIComponent(me.username)}/repos`;
226 const repos = await fetch(reposUrl, { headers: authHeaders(reposUrl) }).then((r) => r.json());
211227 if (Array.isArray(repos)) for (const r of repos) if (r?.name) out.add(`${me.username}/${r.name}`);
212228 }
213229 } catch { /* fall through */ }
@@ -229,10 +245,11 @@ async function crawlSite(startUrl: string, maxPages = 150): Promise<Row[]> {
229245 seen.add(url);
230246 const row = await hit("crawl", new URL(url).pathname || "/", url);
231247 rows.push(row);
232 // Only expand links from pages that rendered.
233 if (row.status >= 200 && row.status < 300 && !row.note) {
248 // Only expand links from pages that rendered. Reuse the body hit()
249 // already captured — no second fetch of a (possibly slow) page.
250 if (row.status >= 200 && row.status < 300 && !row.note && row.body) {
234251 try {
235 const html = await fetch(url, { headers: authHeaders() }).then((r) => r.text());
252 const html = row.body;
236253 const hrefs = [...html.matchAll(/href="([^"#?]+)"/gi)].map((m) => m[1]);
237254 for (const href of hrefs) {
238255 if (DANGER.test(href)) continue;
239256