Spaces:
Running
Running
feat: password-protect env-builder with GATEWAY_TOKEN
Browse files- GET /env-builder — shows login form if not authed
- POST /env-builder/login — validates GATEWAY_TOKEN, sets httpOnly cookie
- GET /env-builder/logout — clears cookie
- GET /env-builder.js — 401 if not authed (prevents unauthenticated JS load)
- Constant-time token comparison to prevent timing attacks
- Cookie: HttpOnly, SameSite=Strict, 24h TTL, scoped to /env-builder
- No protection when GATEWAY_TOKEN is unset (dev mode)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- health-server.js +88 -0
health-server.js
CHANGED
|
@@ -159,6 +159,65 @@ function escapeHtml(v) {
|
|
| 159 |
return String(v).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
| 160 |
}
|
| 161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
function badge(label, tone = "neutral") {
|
| 163 |
return `<span class="badge ${tone}">${escapeHtml(label)}</span>`;
|
| 164 |
}
|
|
@@ -443,16 +502,45 @@ const server = http.createServer(async (req, res) => {
|
|
| 443 |
typeof req.headers.host === "string" &&
|
| 444 |
req.headers.host.endsWith(".hf.space");
|
| 445 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
if (pathname === "/env-builder" || pathname === "/env-builder/") {
|
| 447 |
if (isDirectHfSpaceRequest) {
|
| 448 |
res.writeHead(200, { "Content-Type": "text/html" });
|
| 449 |
return res.end(renderPrivateRedirect(HF_SPACE_URL));
|
| 450 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
res.writeHead(200, { "Content-Type": "text/html" });
|
| 452 |
return res.end(renderEnvBuilder());
|
| 453 |
}
|
| 454 |
|
| 455 |
if (pathname === "/env-builder.js") {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
try {
|
| 457 |
const js = fs.readFileSync(require("path").join(__dirname, "env-builder.js"), "utf8");
|
| 458 |
res.writeHead(200, { "Content-Type": "application/javascript" });
|
|
|
|
| 159 |
return String(v).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
| 160 |
}
|
| 161 |
|
| 162 |
+
function parseCookies(req) {
|
| 163 |
+
const h = req.headers.cookie || "";
|
| 164 |
+
return Object.fromEntries(h.split(";").map(c => c.trim().split("=")).filter(p => p.length >= 2).map(([k, ...v]) => [k.trim(), decodeURIComponent(v.join("=").trim())]));
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
// Constant-time comparison — prevent timing attacks on token check
|
| 168 |
+
function safeEqual(a, b) {
|
| 169 |
+
if (typeof a !== "string" || typeof b !== "string" || a.length !== b.length) return false;
|
| 170 |
+
let d = 0;
|
| 171 |
+
for (let i = 0; i < a.length; i++) d |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
| 172 |
+
return d === 0;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
function isEnvBuilderAuthed(req) {
|
| 176 |
+
if (!GATEWAY_TOKEN) return true; // unprotected when no token set
|
| 177 |
+
return safeEqual(parseCookies(req).hc_env_auth || "", GATEWAY_TOKEN);
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
function readBody(req) {
|
| 181 |
+
return new Promise((resolve) => {
|
| 182 |
+
let body = "";
|
| 183 |
+
req.on("data", chunk => { body += chunk; if (body.length > 4096) { body = ""; req.destroy(); } });
|
| 184 |
+
req.on("end", () => resolve(body));
|
| 185 |
+
req.on("error", () => resolve(""));
|
| 186 |
+
});
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
function renderEnvBuilderLogin(error = false) {
|
| 190 |
+
return `<!doctype html><html lang="en"><head>
|
| 191 |
+
<meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
|
| 192 |
+
<title>HuggingClaw — Env Builder</title>
|
| 193 |
+
<style>
|
| 194 |
+
:root{color-scheme:dark;--bg:#08080f;--panel:#12111b;--line:#26243a;--text:#f6f4ff;--muted:#7f7a9e;--bad:#fb7185}
|
| 195 |
+
*{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}
|
| 196 |
+
.card{border:1px solid var(--line);background:var(--panel);border-radius:14px;padding:36px 32px;max-width:400px;width:100%;text-align:center}
|
| 197 |
+
h1{margin:0 0 8px;font-size:1.4rem}
|
| 198 |
+
.sub{color:var(--muted);font-size:.82rem;margin:0 0 24px}
|
| 199 |
+
.row{display:flex;gap:8px;margin-top:16px}
|
| 200 |
+
input{flex:1;background:#0d0c18;border:1px solid var(--line);border-radius:7px;padding:10px 12px;color:var(--text);font-size:.95rem;outline:none}
|
| 201 |
+
input:focus{border-color:#6366f1}
|
| 202 |
+
button{background:#fff;color:#000;border:none;border-radius:7px;padding:10px 20px;font-weight:700;font-size:.95rem;cursor:pointer;transition:opacity .15s}
|
| 203 |
+
button:hover{opacity:.85}
|
| 204 |
+
.err{color:var(--bad);font-size:.82rem;margin-top:10px}
|
| 205 |
+
code{background:#232234;border:1px solid #34324c;border-radius:5px;padding:2px 6px;font-size:.88em}
|
| 206 |
+
</style></head><body>
|
| 207 |
+
<div class="card">
|
| 208 |
+
<h1>⚙️ Env Builder</h1>
|
| 209 |
+
<p class="sub">Enter your <code>GATEWAY_TOKEN</code> to continue</p>
|
| 210 |
+
<form method="post" action="/env-builder/login">
|
| 211 |
+
<div class="row">
|
| 212 |
+
<input type="password" name="token" placeholder="GATEWAY_TOKEN" autofocus autocomplete="current-password">
|
| 213 |
+
<button type="submit">Unlock</button>
|
| 214 |
+
</div>
|
| 215 |
+
${error ? '<p class="err">Invalid token — try again</p>' : ""}
|
| 216 |
+
</form>
|
| 217 |
+
</div>
|
| 218 |
+
</body></html>`;
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
function badge(label, tone = "neutral") {
|
| 222 |
return `<span class="badge ${tone}">${escapeHtml(label)}</span>`;
|
| 223 |
}
|
|
|
|
| 502 |
typeof req.headers.host === "string" &&
|
| 503 |
req.headers.host.endsWith(".hf.space");
|
| 504 |
|
| 505 |
+
if (pathname === "/env-builder/login") {
|
| 506 |
+
if (req.method === "POST") {
|
| 507 |
+
const body = await readBody(req);
|
| 508 |
+
const token = decodeURIComponent((body.match(/(?:^|&)token=([^&]*)/) || [])[1] || "").replace(/\+/g, " ");
|
| 509 |
+
if (safeEqual(token, GATEWAY_TOKEN)) {
|
| 510 |
+
const cookie = `hc_env_auth=${encodeURIComponent(GATEWAY_TOKEN)}; Path=/env-builder; HttpOnly; SameSite=Strict; Max-Age=86400`;
|
| 511 |
+
res.writeHead(302, { Location: "/env-builder", "Set-Cookie": cookie, "Cache-Control": "no-store" });
|
| 512 |
+
return res.end();
|
| 513 |
+
}
|
| 514 |
+
res.writeHead(200, { "Content-Type": "text/html" });
|
| 515 |
+
return res.end(renderEnvBuilderLogin(true));
|
| 516 |
+
}
|
| 517 |
+
res.writeHead(302, { Location: "/env-builder", "Cache-Control": "no-store" });
|
| 518 |
+
return res.end();
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
if (pathname === "/env-builder/logout") {
|
| 522 |
+
res.writeHead(302, { Location: "/env-builder", "Set-Cookie": "hc_env_auth=; Path=/env-builder; HttpOnly; Max-Age=0", "Cache-Control": "no-store" });
|
| 523 |
+
return res.end();
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
if (pathname === "/env-builder" || pathname === "/env-builder/") {
|
| 527 |
if (isDirectHfSpaceRequest) {
|
| 528 |
res.writeHead(200, { "Content-Type": "text/html" });
|
| 529 |
return res.end(renderPrivateRedirect(HF_SPACE_URL));
|
| 530 |
}
|
| 531 |
+
if (!isEnvBuilderAuthed(req)) {
|
| 532 |
+
res.writeHead(200, { "Content-Type": "text/html" });
|
| 533 |
+
return res.end(renderEnvBuilderLogin(false));
|
| 534 |
+
}
|
| 535 |
res.writeHead(200, { "Content-Type": "text/html" });
|
| 536 |
return res.end(renderEnvBuilder());
|
| 537 |
}
|
| 538 |
|
| 539 |
if (pathname === "/env-builder.js") {
|
| 540 |
+
if (!isEnvBuilderAuthed(req)) {
|
| 541 |
+
res.writeHead(401, { "Content-Type": "text/plain" });
|
| 542 |
+
return res.end("Unauthorized");
|
| 543 |
+
}
|
| 544 |
try {
|
| 545 |
const js = fs.readFileSync(require("path").join(__dirname, "env-builder.js"), "utf8");
|
| 546 |
res.writeHead(200, { "Content-Type": "application/javascript" });
|