somratpro Claude Sonnet 4.6 commited on
Commit
1c989b2
·
1 Parent(s): 8ba914e

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>

Files changed (1) hide show
  1. 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 on token check
170
- function safeEqual(a, b) {
171
- if (typeof a !== "string" || typeof b !== "string" || a.length !== b.length) return false;
172
- let d = 0;
173
- for (let i = 0; i < a.length; i++) d |= a.charCodeAt(i) ^ b.charCodeAt(i);
174
- return d === 0;
 
175
  }
176
 
177
- function isEnvBuilderAuthed(req) {
178
- if (!GATEWAY_TOKEN) return true; // unprotected when no token set
179
- return safeEqual(parseCookies(req).hc_env_auth || "", GATEWAY_TOKEN);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  }
181
 
182
  function readBody(req) {
@@ -188,10 +232,11 @@ function readBody(req) {
188
  });
189
  }
190
 
191
- function renderEnvBuilderLogin(error = false) {
 
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 — Env Builder</title>
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>⚙️ Env Builder</h1>
211
  <p class="sub">Enter your <code>GATEWAY_TOKEN</code> to continue</p>
212
- <form method="post" action="/env-builder/login">
 
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 === "/env-builder/login") {
 
 
 
 
 
 
 
 
 
 
 
 
508
  if (req.method === "POST") {
509
  const body = await readBody(req);
510
- const token = decodeURIComponent((body.match(/(?:^|&)token=([^&]*)/) || [])[1] || "").replace(/\+/g, " ");
511
- if (safeEqual(token, GATEWAY_TOKEN)) {
512
- const cookie = `hc_env_auth=${encodeURIComponent(GATEWAY_TOKEN)}; Path=/env-builder; HttpOnly; SameSite=Strict; Max-Age=86400`;
513
- res.writeHead(302, { Location: "/env-builder", "Set-Cookie": cookie, "Cache-Control": "no-store" });
 
514
  return res.end();
515
  }
516
- res.writeHead(200, { "Content-Type": "text/html" });
517
- return res.end(renderEnvBuilderLogin(true));
518
  }
519
- res.writeHead(302, { Location: "/env-builder", "Cache-Control": "no-store" });
520
- return res.end();
521
  }
522
 
523
- if (pathname === "/env-builder/logout") {
524
- res.writeHead(302, { Location: "/env-builder", "Set-Cookie": "hc_env_auth=; Path=/env-builder; HttpOnly; Max-Age=0", "Cache-Control": "no-store" });
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 (!isEnvBuilderAuthed(req)) {
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 (!isEnvBuilderAuthed(req)) {
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