Spaces:
Running
Running
refactor: port HuggingMes auth pattern — unified /login, HMAC session, crypto.timingSafeEqual
Browse files- Replace per-route env-builder cookie auth with a single /login + /logout route
- HMAC-SHA256 session token (huggingclaw-session-v1) instead of raw GATEWAY_TOKEN in cookie
- Use crypto.timingSafeEqual to prevent timing-attack token comparisons
- Session cookie now uses Path=/ (fixes /env-builder.js 401 that caused empty page)
- requireAuth() helper redirects any protected route → /login?next=<path>
- Gate /terminal/ (JupyterLab) and /env-builder + /env-builder.js with requireAuth()
- Remove old /env-builder/login and /env-builder/logout routes
- If already authed, /login redirects to ?next= instead of showing form
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- health-server.js +89 -35
health-server.js
CHANGED
|
@@ -3,6 +3,7 @@ const http = require("http");
|
|
| 3 |
const https = require("https");
|
| 4 |
const fs = require("fs");
|
| 5 |
const net = require("net");
|
|
|
|
| 6 |
|
| 7 |
function isTrue(value) {
|
| 8 |
return /^(true|1|yes|on)$/i.test(String(value || "").trim());
|
|
@@ -21,6 +22,8 @@ const JUPYTER_PORT = Number.parseInt(process.env.JUPYTER_PORT || "8888", 10);
|
|
| 21 |
const JUPYTER_HOST = "127.0.0.1";
|
| 22 |
const JUPYTER_BASE = normalizeBase(process.env.JUPYTER_BASE, "/terminal");
|
| 23 |
const GATEWAY_TOKEN = (process.env.GATEWAY_TOKEN || "").trim();
|
|
|
|
|
|
|
| 24 |
const DEV_MODE_ENABLED = isTrue(process.env.DEV_MODE);
|
| 25 |
// Default true. Only false when DEV_MODE=false or HUGGINGCLAW_JUPYTER_ENABLED=false is explicitly set.
|
| 26 |
const JUPYTER_ENABLED =
|
|
@@ -166,17 +169,58 @@ function parseCookies(req) {
|
|
| 166 |
return Object.fromEntries(h.split(";").map(c => c.trim().split("=")).filter(p => p.length >= 2).map(([k, ...v]) => [k.trim(), decodeURIComponent(v.join("=").trim())]));
|
| 167 |
}
|
| 168 |
|
| 169 |
-
// Constant-time comparison — prevent timing attacks
|
| 170 |
-
function
|
| 171 |
-
if (
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
|
|
|
| 175 |
}
|
| 176 |
|
| 177 |
-
function
|
| 178 |
-
if (!GATEWAY_TOKEN) return
|
| 179 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
}
|
| 181 |
|
| 182 |
function readBody(req) {
|
|
@@ -188,10 +232,11 @@ function readBody(req) {
|
|
| 188 |
});
|
| 189 |
}
|
| 190 |
|
| 191 |
-
function
|
|
|
|
| 192 |
return `<!doctype html><html lang="en"><head>
|
| 193 |
<meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
|
| 194 |
-
<title>HuggingClaw
|
| 195 |
<style>
|
| 196 |
:root{color-scheme:dark;--bg:#08080f;--panel:#12111b;--line:#26243a;--text:#f6f4ff;--muted:#7f7a9e;--bad:#fb7185}
|
| 197 |
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);padding:24px}
|
|
@@ -199,19 +244,20 @@ function renderEnvBuilderLogin(error = false) {
|
|
| 199 |
h1{margin:0 0 8px;font-size:1.4rem}
|
| 200 |
.sub{color:var(--muted);font-size:.82rem;margin:0 0 24px}
|
| 201 |
.row{display:flex;gap:8px;margin-top:16px}
|
| 202 |
-
input{flex:1;background:#0d0c18;border:1px solid var(--line);border-radius:7px;padding:10px 12px;color:var(--text);font-size:.95rem;outline:none}
|
| 203 |
input:focus{border-color:#6366f1}
|
| 204 |
-
button{background:#fff;color:#000;border:none;border-radius:7px;padding:10px 20px;font-weight:700;font-size:.95rem;cursor:pointer;transition:opacity .15s}
|
| 205 |
button:hover{opacity:.85}
|
| 206 |
.err{color:var(--bad);font-size:.82rem;margin-top:10px}
|
| 207 |
code{background:#232234;border:1px solid #34324c;border-radius:5px;padding:2px 6px;font-size:.88em}
|
| 208 |
</style></head><body>
|
| 209 |
<div class="card">
|
| 210 |
-
<h1>
|
| 211 |
<p class="sub">Enter your <code>GATEWAY_TOKEN</code> to continue</p>
|
| 212 |
-
<form method="post" action="
|
|
|
|
| 213 |
<div class="row">
|
| 214 |
-
<input type="password" name="token" placeholder="GATEWAY_TOKEN" autofocus autocomplete="current-password">
|
| 215 |
<button type="submit">Unlock</button>
|
| 216 |
</div>
|
| 217 |
${error ? '<p class="err">Invalid token — try again</p>' : ""}
|
|
@@ -504,24 +550,37 @@ const server = http.createServer(async (req, res) => {
|
|
| 504 |
typeof req.headers.host === "string" &&
|
| 505 |
req.headers.host.endsWith(".hf.space");
|
| 506 |
|
| 507 |
-
if (pathname ===
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 508 |
if (req.method === "POST") {
|
| 509 |
const body = await readBody(req);
|
| 510 |
-
const
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
|
|
|
| 514 |
return res.end();
|
| 515 |
}
|
| 516 |
-
res.writeHead(
|
| 517 |
-
return res.end(
|
| 518 |
}
|
| 519 |
-
res.writeHead(
|
| 520 |
-
return res.end();
|
| 521 |
}
|
| 522 |
|
| 523 |
-
if (pathname === "/
|
| 524 |
-
res.writeHead(302, { Location:
|
| 525 |
return res.end();
|
| 526 |
}
|
| 527 |
|
|
@@ -530,19 +589,13 @@ const server = http.createServer(async (req, res) => {
|
|
| 530 |
res.writeHead(200, { "Content-Type": "text/html" });
|
| 531 |
return res.end(renderPrivateRedirect(HF_SPACE_URL));
|
| 532 |
}
|
| 533 |
-
if (!
|
| 534 |
-
res.writeHead(200, { "Content-Type": "text/html" });
|
| 535 |
-
return res.end(renderEnvBuilderLogin(false));
|
| 536 |
-
}
|
| 537 |
res.writeHead(200, { "Content-Type": "text/html" });
|
| 538 |
return res.end(renderEnvBuilder());
|
| 539 |
}
|
| 540 |
|
| 541 |
if (pathname === "/env-builder.js") {
|
| 542 |
-
if (!
|
| 543 |
-
res.writeHead(401, { "Content-Type": "text/plain" });
|
| 544 |
-
return res.end("Unauthorized");
|
| 545 |
-
}
|
| 546 |
try {
|
| 547 |
const js = fs.readFileSync(require("path").join(__dirname, "env-builder.js"), "utf8");
|
| 548 |
res.writeHead(200, { "Content-Type": "application/javascript" });
|
|
@@ -576,6 +629,7 @@ const server = http.createServer(async (req, res) => {
|
|
| 576 |
res.writeHead(200, { "Content-Type": "text/html" });
|
| 577 |
return res.end(renderPrivateRedirect(HF_SPACE_URL));
|
| 578 |
}
|
|
|
|
| 579 |
return proxyHTTP(req, res, JUPYTER_HOST, JUPYTER_PORT, {
|
| 580 |
publicPrefix: JUPYTER_BASE,
|
| 581 |
// Jupyter is started with --ServerApp.base_url=/terminal/, so keep the
|
|
|
|
| 3 |
const https = require("https");
|
| 4 |
const fs = require("fs");
|
| 5 |
const net = require("net");
|
| 6 |
+
const crypto = require("crypto");
|
| 7 |
|
| 8 |
function isTrue(value) {
|
| 9 |
return /^(true|1|yes|on)$/i.test(String(value || "").trim());
|
|
|
|
| 22 |
const JUPYTER_HOST = "127.0.0.1";
|
| 23 |
const JUPYTER_BASE = normalizeBase(process.env.JUPYTER_BASE, "/terminal");
|
| 24 |
const GATEWAY_TOKEN = (process.env.GATEWAY_TOKEN || "").trim();
|
| 25 |
+
const SESSION_COOKIE = "hc_session";
|
| 26 |
+
const LOGIN_PATH = "/login";
|
| 27 |
const DEV_MODE_ENABLED = isTrue(process.env.DEV_MODE);
|
| 28 |
// Default true. Only false when DEV_MODE=false or HUGGINGCLAW_JUPYTER_ENABLED=false is explicitly set.
|
| 29 |
const JUPYTER_ENABLED =
|
|
|
|
| 169 |
return Object.fromEntries(h.split(";").map(c => c.trim().split("=")).filter(p => p.length >= 2).map(([k, ...v]) => [k.trim(), decodeURIComponent(v.join("=").trim())]));
|
| 170 |
}
|
| 171 |
|
| 172 |
+
// Constant-time comparison using crypto — prevent timing attacks
|
| 173 |
+
function timingSafeEqualString(a, b) {
|
| 174 |
+
if (!a || !b) return false;
|
| 175 |
+
const bufA = Buffer.from(a);
|
| 176 |
+
const bufB = Buffer.from(b);
|
| 177 |
+
if (bufA.length !== bufB.length) return false;
|
| 178 |
+
return crypto.timingSafeEqual(bufA, bufB);
|
| 179 |
}
|
| 180 |
|
| 181 |
+
function expectedSessionValue() {
|
| 182 |
+
if (!GATEWAY_TOKEN) return "";
|
| 183 |
+
return crypto.createHmac("sha256", GATEWAY_TOKEN).update("huggingclaw-session-v1").digest("hex");
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
function isHttpsRequest(req) {
|
| 187 |
+
return req.headers["x-forwarded-proto"] === "https";
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
function buildSessionCookie(req) {
|
| 191 |
+
const secure = isHttpsRequest(req) ? "; Secure" : "";
|
| 192 |
+
return `${SESSION_COOKIE}=${encodeURIComponent(expectedSessionValue())}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400${secure}`;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
function getBearerToken(req) {
|
| 196 |
+
const match = /^Bearer\s+(.+)$/i.exec(req.headers.authorization || "");
|
| 197 |
+
return match ? match[1] : "";
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
function isAuthorized(req) {
|
| 201 |
+
if (!GATEWAY_TOKEN) return true;
|
| 202 |
+
return (
|
| 203 |
+
timingSafeEqualString(getBearerToken(req), GATEWAY_TOKEN) ||
|
| 204 |
+
timingSafeEqualString(parseCookies(req)[SESSION_COOKIE], expectedSessionValue())
|
| 205 |
+
);
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
function sanitizeNext(value) {
|
| 209 |
+
if (!value || typeof value !== "string") return "/";
|
| 210 |
+
if (!value.startsWith("/") || value.startsWith("//")) return "/";
|
| 211 |
+
return value;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
function loginUrl(nextPath) {
|
| 215 |
+
return `${LOGIN_PATH}?next=${encodeURIComponent(sanitizeNext(nextPath))}`;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
function requireAuth(req, res) {
|
| 219 |
+
if (isAuthorized(req)) return true;
|
| 220 |
+
const parsed = parseRequestUrl(req.url);
|
| 221 |
+
res.writeHead(302, { Location: loginUrl(parsed.pathname + parsed.search), "Cache-Control": "no-store" });
|
| 222 |
+
res.end();
|
| 223 |
+
return false;
|
| 224 |
}
|
| 225 |
|
| 226 |
function readBody(req) {
|
|
|
|
| 232 |
});
|
| 233 |
}
|
| 234 |
|
| 235 |
+
function renderLoginPage(nextPath = "/", error = false) {
|
| 236 |
+
const safeNext = sanitizeNext(nextPath);
|
| 237 |
return `<!doctype html><html lang="en"><head>
|
| 238 |
<meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
|
| 239 |
+
<title>HuggingClaw</title>
|
| 240 |
<style>
|
| 241 |
:root{color-scheme:dark;--bg:#08080f;--panel:#12111b;--line:#26243a;--text:#f6f4ff;--muted:#7f7a9e;--bad:#fb7185}
|
| 242 |
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);padding:24px}
|
|
|
|
| 244 |
h1{margin:0 0 8px;font-size:1.4rem}
|
| 245 |
.sub{color:var(--muted);font-size:.82rem;margin:0 0 24px}
|
| 246 |
.row{display:flex;gap:8px;margin-top:16px}
|
| 247 |
+
input{flex:1;background:#0d0c18;border:1px solid var(--line);border-radius:7px;padding:10px 12px;color:var(--text);font-size:.95rem;outline:none;transition:border-color .15s}
|
| 248 |
input:focus{border-color:#6366f1}
|
| 249 |
+
button{background:#fff;color:#000;border:none;border-radius:7px;padding:10px 20px;font-weight:700;font-size:.95rem;cursor:pointer;transition:opacity .15s;white-space:nowrap}
|
| 250 |
button:hover{opacity:.85}
|
| 251 |
.err{color:var(--bad);font-size:.82rem;margin-top:10px}
|
| 252 |
code{background:#232234;border:1px solid #34324c;border-radius:5px;padding:2px 6px;font-size:.88em}
|
| 253 |
</style></head><body>
|
| 254 |
<div class="card">
|
| 255 |
+
<h1>🦞 HuggingClaw</h1>
|
| 256 |
<p class="sub">Enter your <code>GATEWAY_TOKEN</code> to continue</p>
|
| 257 |
+
<form method="post" action="${LOGIN_PATH}">
|
| 258 |
+
<input type="hidden" name="next" value="${escapeHtml(safeNext)}" />
|
| 259 |
<div class="row">
|
| 260 |
+
<input type="password" name="token" placeholder="GATEWAY_TOKEN" autofocus autocomplete="current-password" required>
|
| 261 |
<button type="submit">Unlock</button>
|
| 262 |
</div>
|
| 263 |
${error ? '<p class="err">Invalid token — try again</p>' : ""}
|
|
|
|
| 550 |
typeof req.headers.host === "string" &&
|
| 551 |
req.headers.host.endsWith(".hf.space");
|
| 552 |
|
| 553 |
+
if (pathname === LOGIN_PATH) {
|
| 554 |
+
if (isAuthorized(req)) {
|
| 555 |
+
const parsed = parseRequestUrl(req.url);
|
| 556 |
+
const next = sanitizeNext(parsed.searchParams.get("next") || "/");
|
| 557 |
+
res.writeHead(302, { Location: next, "Cache-Control": "no-store" });
|
| 558 |
+
return res.end();
|
| 559 |
+
}
|
| 560 |
+
if (req.method === "GET") {
|
| 561 |
+
const parsed = parseRequestUrl(req.url);
|
| 562 |
+
const next = sanitizeNext(parsed.searchParams.get("next") || "/");
|
| 563 |
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
| 564 |
+
return res.end(renderLoginPage(next, false));
|
| 565 |
+
}
|
| 566 |
if (req.method === "POST") {
|
| 567 |
const body = await readBody(req);
|
| 568 |
+
const params = new URLSearchParams(body);
|
| 569 |
+
const submittedToken = params.get("token") || "";
|
| 570 |
+
const next = sanitizeNext(params.get("next") || "/");
|
| 571 |
+
if (!GATEWAY_TOKEN || timingSafeEqualString(submittedToken, GATEWAY_TOKEN)) {
|
| 572 |
+
res.writeHead(302, { Location: next, "Set-Cookie": buildSessionCookie(req), "Cache-Control": "no-store" });
|
| 573 |
return res.end();
|
| 574 |
}
|
| 575 |
+
res.writeHead(401, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
| 576 |
+
return res.end(renderLoginPage(next, true));
|
| 577 |
}
|
| 578 |
+
res.writeHead(405, { Allow: "GET, POST" });
|
| 579 |
+
return res.end("Method Not Allowed");
|
| 580 |
}
|
| 581 |
|
| 582 |
+
if (pathname === "/logout") {
|
| 583 |
+
res.writeHead(302, { Location: LOGIN_PATH, "Set-Cookie": `${SESSION_COOKIE}=; Path=/; HttpOnly; Max-Age=0`, "Cache-Control": "no-store" });
|
| 584 |
return res.end();
|
| 585 |
}
|
| 586 |
|
|
|
|
| 589 |
res.writeHead(200, { "Content-Type": "text/html" });
|
| 590 |
return res.end(renderPrivateRedirect(HF_SPACE_URL));
|
| 591 |
}
|
| 592 |
+
if (!requireAuth(req, res)) return;
|
|
|
|
|
|
|
|
|
|
| 593 |
res.writeHead(200, { "Content-Type": "text/html" });
|
| 594 |
return res.end(renderEnvBuilder());
|
| 595 |
}
|
| 596 |
|
| 597 |
if (pathname === "/env-builder.js") {
|
| 598 |
+
if (!requireAuth(req, res)) return;
|
|
|
|
|
|
|
|
|
|
| 599 |
try {
|
| 600 |
const js = fs.readFileSync(require("path").join(__dirname, "env-builder.js"), "utf8");
|
| 601 |
res.writeHead(200, { "Content-Type": "application/javascript" });
|
|
|
|
| 629 |
res.writeHead(200, { "Content-Type": "text/html" });
|
| 630 |
return res.end(renderPrivateRedirect(HF_SPACE_URL));
|
| 631 |
}
|
| 632 |
+
if (!requireAuth(req, res)) return;
|
| 633 |
return proxyHTTP(req, res, JUPYTER_HOST, JUPYTER_PORT, {
|
| 634 |
publicPrefix: JUPYTER_BASE,
|
| 635 |
// Jupyter is started with --ServerApp.base_url=/terminal/, so keep the
|