fix: remove 302 redirect, serve page directly with embedded token

The previous fix (84e0caa) embedded the token in HTML but still did a
302 redirect on first visit. This meant:
1. Phone opens URL with ?token=xxx
2. Server redirects to / (token stripped from URL)
3. redirected request has no token, buildHTML gets undefined
4. Token never embedded in the page

Also the old code passed the server's secret 'token' variable instead of
the URL parameter 'providedToken' — a security issue.

Fix: Remove the redirect entirely. Serve the HTML page directly on first
visit with Set-Cookie header (no Location redirect). Pass the actual
providedToken from the URL to buildHTML so it gets embedded correctly.
The JS includes the token in the WebSocket URL, so auth works even if
the cookie isn't available.
This commit is contained in:
Marc 2026-04-12 05:40:05 -06:00
parent 84e0caa1d3
commit e15f841036
1 changed files with 42 additions and 44 deletions

View File

@ -103,30 +103,28 @@ export function startServer(pi: ExtensionAPI, ctx: ExtensionContext): Promise<Re
return; return;
} }
// If authenticated via token (first visit), issue a session cookie and redirect to clean URL // If authenticated via token (first visit), register a session cookie but serve the
if (!hasValidSession && hasValidToken) { // page directly — don't redirect. The token is embedded in the HTML so the JS
pruneExpiredSessions(); // can include it in the WebSocket URL. Avoids cookie/redirect issues on mobile.
const sessionId = generateSessionId(); let extraHeaders: Record<string, string> = {};
validSessions.set(sessionId, Date.now() + SESSION_TTL_MS); if (!hasValidSession && hasValidToken) {
res.writeHead(302, { pruneExpiredSessions();
"Set-Cookie": `${SESSION_COOKIE}=${sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`, const sessionId = generateSessionId();
Location: "/", validSessions.set(sessionId, Date.now() + SESSION_TTL_MS);
}); extraHeaders["Set-Cookie"] = `${SESSION_COOKIE}=${sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`;
res.end(); }
return;
}
// Valid session cookie — serve the page const nonce = randomBytes(16).toString("base64");
const nonce = randomBytes(16).toString("base64"); res.writeHead(200, {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8",
"Content-Type": "text/html; charset=utf-8", "X-Frame-Options": "DENY",
"X-Frame-Options": "DENY", "X-Content-Type-Options": "nosniff",
"X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer",
"Referrer-Policy": "no-referrer", "Content-Security-Policy":
"Content-Security-Policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'`,
`default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'`, ...extraHeaders,
}); });
res.end(buildHTML(nonce, token)); res.end(buildHTML(nonce, hasValidToken ? providedToken : undefined));
} else { } else {
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Not found"); res.end("Not found");
@ -329,28 +327,28 @@ export function startServerTailscale(pi: ExtensionAPI, ctx: ExtensionContext): P
return; return;
} }
if (!hasValidSession && hasValidToken) { // If authenticated via token (first visit), register a session cookie but serve the
pruneExpiredSessions(); // page directly — don't redirect. The token is embedded in the HTML so the JS
const sessionId = generateSessionId(); // can include it in the WebSocket URL. Avoids cookie/redirect issues on mobile.
validSessions.set(sessionId, Date.now() + SESSION_TTL_MS); let extraHeaders: Record<string, string> = {};
res.writeHead(302, { if (!hasValidSession && hasValidToken) {
"Set-Cookie": `${SESSION_COOKIE}=${sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`, pruneExpiredSessions();
Location: "/", const sessionId = generateSessionId();
}); validSessions.set(sessionId, Date.now() + SESSION_TTL_MS);
res.end(); extraHeaders["Set-Cookie"] = `${SESSION_COOKIE}=${sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`;
return; }
}
const nonce = randomBytes(16).toString("base64"); const nonce = randomBytes(16).toString("base64");
res.writeHead(200, { res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8", "Content-Type": "text/html; charset=utf-8",
"X-Frame-Options": "DENY", "X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff", "X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer", "Referrer-Policy": "no-referrer",
"Content-Security-Policy": "Content-Security-Policy":
`default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'`, `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'`,
}); ...extraHeaders,
res.end(buildHTML(nonce, token)); });
res.end(buildHTML(nonce, hasValidToken ? providedToken : undefined));
} else { } else {
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Not found"); res.end("Not found");