Skip to content

Commit d66e56a

Browse files
committed
fix(auth): serve sign-in assets before authentication
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
1 parent 3a61e86 commit d66e56a

5 files changed

Lines changed: 129 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ product overview.
1818
1919
## Unreleased
2020

21+
- Fix signed-out admin login: allow the login script, favicon and required fonts before
22+
authentication so a fresh browser can submit the sign-in form.
2123
- Security hardening (audit batch 2): a channel's custom work folder can no longer be the gateway
2224
account's home, the gateway runtime root, `~/.ssh`, `~/.claude`, `~/.codex`, the container
2325
engine's storage, the hidden artifact trees, the ChannelGate workspace root or its platform

‎FEATURES.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# ChannelGate — Features
22

3+
## Sign-in assets before authentication
4+
5+
- The signed-out admin page serves its login script, favicon and three Poppins font files without
6+
a session. These exact public paths prevent authentication redirects from replacing JavaScript
7+
and fonts with login HTML; other dashboard assets and privileged APIs remain session-gated.
8+
39
## Work-folder boundary and protected export names (security audit batch 2)
410

511
- **Sensitive roots are never a work folder.** Whatever the allowlisted `fsRoot` admits, a channel's

‎TEST-PLAN.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
# ChannelGate — Test Plan
22

3+
## Signed-out admin login assets (2026-10-25)
4+
5+
- [x] HTTP regression: `node --test test/admin-login-assets.test.js` serves every script, icon and
6+
font referenced by the real login HTML without cookies, with correct MIME types and WOFF2
7+
signatures, including HEAD and versioned requests. Dashboard assets still redirect to login
8+
and `/api/settings` still answers 401. The regression fails against the previous auth gate.
9+
- [x] Browser regression: set `CG_BROWSER_MODULE` to an installed Playwright module and run
10+
`node --test test/admin-login-assets.test.js`. A fresh browser with no cookies opens `/`, sees
11+
sign-in, submits a wrong password and sees its error, then submits the disposable fixture
12+
password and reaches the authenticated dashboard. Log out and repeat sign-in. PASS when both
13+
sign-ins work, the session is HttpOnly/SameSite=Strict, and there are no page script errors.
14+
Executed with headless Chromium: all 24 tests passed across this file,
15+
`test/admin-auth-bind.test.js` and `test/web-security.test.js`, with zero skips.
16+
- [ ] Live, engine-independent: open the deployed public gateway in a fresh browser context;
17+
verify the login script and three font requests return 200 with actual assets, enter the admin
18+
password through the form, reach the dashboard, log out, and sign in again. Verify no
19+
`Unexpected token '<'` or font decode errors. Browser/HTTP authentication does not invoke an
20+
engine, so Claude and Codex share this acceptance case.
21+
322
## Work-folder boundary and protected export names (2026-10-25)
423

524
- [x] Automated (`node scripts/run-tests.mjs <file>`, one file per invocation):

‎src/web/auth.js‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,13 @@ function cookieAttrs(req) {
8484
}
8585

8686
// Paths reachable without a session (so you can actually log in).
87-
const OPEN_PATHS = new Set(["/login", "/login.html", "/api/login", "/api/health", "/api/skills/webhook/github", "/mcp/skills"]);
87+
const OPEN_PATHS = new Set([
88+
"/login", "/login.html", "/api/login", "/api/health", "/api/skills/webhook/github", "/mcp/skills",
89+
// The signed-out page needs its script, icon and fonts before it can submit a password.
90+
// Keep these exact: other static assets and every privileged route retain their session gate.
91+
"/login.js", "/favicon.svg",
92+
"/fonts/poppins-400-latin.woff2", "/fonts/poppins-600-latin.woff2", "/fonts/poppins-700-latin.woff2",
93+
]);
8894

8995
// The HTTP run API accepts a bearer API key as an alternative to the admin session cookie, so an
9096
// automation can fire runs without logging in. It is an admin credential for the RUNS it starts —

‎test/admin-login-assets.test.js‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
import { ensureTestEnv } from "./helpers.js";
4+
5+
ensureTestEnv();
6+
delete process.env.ADMIN_PASSWORD;
7+
delete process.env.CG_API_KEY;
8+
9+
const { saveSettings } = await import("../src/config/settings.js");
10+
const { hashPassword } = await import("../src/web/security.js");
11+
const { createWebApp } = await import("../src/web/app.js");
12+
const password = "disposable-login-browser-fixture";
13+
14+
async function startApp(t) {
15+
saveSettings({ adminPassword: await hashPassword(password), apiKey: "" });
16+
const app = createWebApp();
17+
const server = await new Promise((resolve) => {
18+
const listening = app.listen(0, "127.0.0.1", () => resolve(listening));
19+
});
20+
t.after(() => new Promise((resolve) => { server.closeAllConnections(); server.close(resolve); }));
21+
return `http://127.0.0.1:${server.address().port}`;
22+
}
23+
24+
test("logged-out sign-in serves its real assets while the dashboard stays gated", async (t) => {
25+
const base = await startApp(t);
26+
const page = await fetch(`${base}/login.html`);
27+
assert.equal(page.status, 200);
28+
const html = await page.text();
29+
const assets = [...html.matchAll(/(?:src=|href=|url\()"([^"?]+)(?:\?[^\"]*)?"/g)].map((match) => match[1]);
30+
assert.ok(assets.includes("/login.js"), "exercise the actual login page's script dependency");
31+
assert.ok(assets.some((asset) => asset.endsWith(".woff2")), "exercise the login page's font dependencies");
32+
for (const asset of assets) {
33+
const response = await fetch(`${base}${asset}?v=login-regression`, { redirect: "manual" });
34+
assert.equal(response.status, 200, `${asset} must load without a session or redirect`);
35+
assert.equal(response.headers.get("location"), null);
36+
const bytes = Buffer.from(await response.arrayBuffer());
37+
if (asset.endsWith(".js")) {
38+
assert.match(response.headers.get("content-type"), /javascript/);
39+
assert.match(bytes.toString(), /addEventListener/);
40+
assert.equal(response.headers.get("cache-control"), "no-cache");
41+
} else if (asset.endsWith(".woff2")) {
42+
assert.match(response.headers.get("content-type"), /font\/woff2/);
43+
assert.equal(bytes.subarray(0, 4).toString(), "wOF2");
44+
} else {
45+
assert.match(response.headers.get("content-type"), /image\/svg\+xml/);
46+
}
47+
const head = await fetch(`${base}${asset}`, { method: "HEAD", redirect: "manual" });
48+
assert.equal(head.status, 200);
49+
}
50+
for (const route of ["/", "/app.js", "/styles.css", "/fonts/poppins-500-latin.woff2", "/login.js-extra"]) {
51+
const response = await fetch(`${base}${route}`, { redirect: "manual" });
52+
assert.equal(response.status, 302, `${route} must remain session-gated`);
53+
assert.equal(response.headers.get("location"), "/login.html");
54+
await response.arrayBuffer();
55+
}
56+
const settings = await fetch(`${base}/api/settings`);
57+
assert.equal(settings.status, 401);
58+
await settings.arrayBuffer();
59+
});
60+
61+
test("fresh browser signs in through the form and returns to working sign-in after logout", { skip: !process.env.CG_BROWSER_MODULE }, async (t) => {
62+
const base = await startApp(t);
63+
const { chromium } = await import(process.env.CG_BROWSER_MODULE);
64+
const browser = await chromium.launch({ headless: true, executablePath: process.env.CG_BROWSER_EXECUTABLE });
65+
t.after(() => browser.close());
66+
const context = await browser.newContext();
67+
const page = await context.newPage();
68+
const errors = [];
69+
page.on("pageerror", (error) => errors.push(error.message));
70+
await page.goto(base);
71+
assert.equal(new URL(page.url()).pathname, "/login.html");
72+
assert.deepEqual(await context.cookies(), []);
73+
await page.locator("#pw").fill("wrong-disposable-password");
74+
await page.getByRole("button", { name: "Sign in" }).click();
75+
await page.locator("#err").filter({ hasText: "Wrong password" }).waitFor();
76+
await page.locator("#pw").fill(password);
77+
await Promise.all([
78+
page.waitForURL(`${base}/`),
79+
page.getByRole("button", { name: "Sign in" }).click(),
80+
]);
81+
const session = (await context.cookies()).find((cookie) => cookie.name === "cg_session");
82+
assert.ok(session);
83+
assert.equal(session.httpOnly, true);
84+
assert.equal(session.sameSite, "Strict");
85+
assert.equal(await page.evaluate(async () => (await fetch("/api/settings")).status), 200);
86+
assert.equal(await page.evaluate(async () => (await fetch("/api/logout", { method: "POST" })).status), 200);
87+
await page.goto(base);
88+
assert.equal(new URL(page.url()).pathname, "/login.html");
89+
await page.locator("#pw").fill(password);
90+
await Promise.all([
91+
page.waitForURL(`${base}/`),
92+
page.getByRole("button", { name: "Sign in" }).click(),
93+
]);
94+
assert.deepEqual(errors, []);
95+
});

0 commit comments

Comments
 (0)