From 69f7b8f3dea3dcb8798f0313869f60e919769e13 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Sat, 21 Mar 2026 16:12:15 -0400 Subject: [PATCH 1/2] feat: add password protection for wildcard services Add optional HTTP Basic Auth password protection to any wildcard subdomain service. Passwords are stored as Cloudflare Worker secrets and referenced by name in deploy.json. The deploy script interactively provisions missing secrets via 'wrangler secret put'. - Add optional 'password' field to all route config types - Implement HTTP Basic Auth check in worker before handler dispatch - Add checkBasicAuth() and unauthorizedResponse() utility functions - Add secret management in deploy script (list, prompt, upload) - Add comprehensive tests for auth functions - Update deploy.json schema for password field --- packages/deploy/index.ts | 69 ++++++++++++++++++++++++++++- packages/deploy/schema.json | 12 +++++ packages/wildcard/src/schemas.ts | 3 ++ packages/wildcard/src/utils.test.ts | 64 ++++++++++++++++++++++++++ packages/wildcard/src/utils.ts | 34 ++++++++++++++ services/wildcard/src/index.ts | 14 ++++++ 6 files changed, 195 insertions(+), 1 deletion(-) diff --git a/packages/deploy/index.ts b/packages/deploy/index.ts index be07b14f..2220606f 100755 --- a/packages/deploy/index.ts +++ b/packages/deploy/index.ts @@ -45,7 +45,7 @@ import { $ } from "bun"; import { readdir, stat, access } from "fs/promises"; import { join, relative, resolve } from "path"; -import { intro, outro, spinner } from "@clack/prompts"; +import { intro, outro, spinner, password as passwordPrompt } from "@clack/prompts"; import { z } from "zod"; import { createHash } from "crypto"; import packageJson from "./package.json" with { type: "json" }; @@ -81,14 +81,17 @@ function wrangler(...args: string[]) { const StaticRuleSchema = StaticConfigSchema.safeExtend({ subdomain: subdomain(), dir: z.string().min(1), + password: z.string().optional(), }); const RedirectRuleSchema = RedirectConfigSchema.safeExtend({ subdomain: subdomain(), + password: z.string().optional(), }); const RewriteRuleSchema = RewriteConfigSchema.safeExtend({ subdomain: subdomain(), + password: z.string().optional(), }); const RouteRuleSchema = z.discriminatedUnion("type", [ @@ -364,6 +367,52 @@ async function validateKVAccess(): Promise { } } +/** + * List secrets configured on the wildcard worker + */ +async function listWorkerSecrets(): Promise { + try { + const output = await wrangler("secret", "list", "--name", "just-be-dev-wildcard").text(); + const secrets = JSON.parse(output); + return Array.isArray(secrets) ? secrets.map((s: { name: string }) => s.name) : []; + } catch { + return []; + } +} + +/** + * Ensure a secret exists on the wildcard worker, prompting the user if it doesn't + */ +async function ensureSecretExists( + secretName: string, + s: ReturnType, +): Promise { + s.start(`Checking if secret "${secretName}" exists`); + const existingSecrets = await listWorkerSecrets(); + + if (existingSecrets.includes(secretName)) { + s.stop(`Secret "${secretName}" already exists`); + return; + } + + s.stop(`Secret "${secretName}" not found`); + + const value = await passwordPrompt({ + message: `Enter the password value for secret "${secretName}":`, + }); + + if (typeof value !== "string" || !value) { + console.error("Error: No password provided"); + process.exit(1); + } + + s.start(`Uploading secret "${secretName}"`); + await $`echo ${value}`.pipe( + wrangler("secret", "put", secretName, "--name", "just-be-dev-wildcard"), + ); + s.stop(`Secret "${secretName}" uploaded`); +} + /** * Get the current git branch name */ @@ -487,6 +536,11 @@ async function deployStaticRule(rule: StaticRule, s: ReturnType) console.log(`✓ Uploaded ${uploadCount} files, skipped ${skippedCount} unchanged files`); + // Ensure password secret exists if configured + if (rule.password) { + await ensureSecretExists(rule.password, s); + } + // Create KV entry (path is derived from subdomain at runtime) const routeConfig: RouteConfig = { type: "static", @@ -494,6 +548,7 @@ async function deployStaticRule(rule: StaticRule, s: ReturnType) ...(rule.fallback && { fallback: rule.fallback }), ...(rule.redirects?.length && { redirects: rule.redirects }), ...(rule.rewrites?.length && { rewrites: rule.rewrites }), + ...(rule.password && { password: rule.password }), }; s.start(`Creating KV routing entry`); @@ -512,11 +567,17 @@ async function deployRedirectRule( console.log(` Target URL: ${rule.url}`); console.log(` Permanent: ${rule.permanent ?? false}`); + // Ensure password secret exists if configured + if (rule.password) { + await ensureSecretExists(rule.password, s); + } + const routeConfig: RouteConfig = { type: "redirect", url: rule.url, ...(rule.permanent !== undefined && { permanent: rule.permanent }), ...(rule.preservePath !== undefined && { preservePath: rule.preservePath }), + ...(rule.password && { password: rule.password }), }; s.start(`Creating KV routing entry`); @@ -532,10 +593,16 @@ async function deployRewriteRule(rule: RewriteRule, s: ReturnType (data.spa && data.fallback ? false : true), { message: "fallback cannot be used with spa mode (spa: true)", @@ -47,12 +48,14 @@ export const RedirectConfigSchema = z.object({ url: safeUrl(), permanent: z.boolean().optional(), preservePath: z.boolean().optional(), + password: z.string().optional(), }); export const RewriteConfigSchema = z.object({ type: z.literal("rewrite"), url: safeUrl(), allowedMethods: z.array(HttpMethod).optional().default(["GET", "HEAD", "OPTIONS"]), + password: z.string().optional(), }); export const RouteConfigSchema = z.discriminatedUnion("type", [ diff --git a/packages/wildcard/src/utils.test.ts b/packages/wildcard/src/utils.test.ts index 55092d5e..9da641f9 100644 --- a/packages/wildcard/src/utils.test.ts +++ b/packages/wildcard/src/utils.test.ts @@ -6,6 +6,8 @@ import { filterSafeHeaders, isValidSubdomain, matchPath, + checkBasicAuth, + unauthorizedResponse, SAFE_REQUEST_HEADERS, } from "./utils"; @@ -343,6 +345,68 @@ describe("isValidSubdomain", () => { }); }); +describe("checkBasicAuth", () => { + function requestWithAuth(username: string, password: string): Request { + const encoded = btoa(`${username}:${password}`); + return new Request("https://example.com", { + headers: { Authorization: `Basic ${encoded}` }, + }); + } + + it("should return true for correct password with any username", () => { + expect(checkBasicAuth(requestWithAuth("user", "secret"), "secret")).toBe(true); + expect(checkBasicAuth(requestWithAuth("admin", "secret"), "secret")).toBe(true); + expect(checkBasicAuth(requestWithAuth("", "secret"), "secret")).toBe(true); + }); + + it("should return false for wrong password", () => { + expect(checkBasicAuth(requestWithAuth("user", "wrong"), "secret")).toBe(false); + }); + + it("should return false when no Authorization header", () => { + const request = new Request("https://example.com"); + expect(checkBasicAuth(request, "secret")).toBe(false); + }); + + it("should return false for non-Basic auth schemes", () => { + const request = new Request("https://example.com", { + headers: { Authorization: "Bearer token123" }, + }); + expect(checkBasicAuth(request, "secret")).toBe(false); + }); + + it("should return false for malformed base64", () => { + const request = new Request("https://example.com", { + headers: { Authorization: "Basic !!!invalid!!!" }, + }); + expect(checkBasicAuth(request, "secret")).toBe(false); + }); + + it("should return false for base64 without colon separator", () => { + const encoded = btoa("nocolon"); + const request = new Request("https://example.com", { + headers: { Authorization: `Basic ${encoded}` }, + }); + expect(checkBasicAuth(request, "nocolon")).toBe(false); + }); + + it("should handle passwords containing colons", () => { + expect(checkBasicAuth(requestWithAuth("user", "pass:word:here"), "pass:word:here")).toBe(true); + }); +}); + +describe("unauthorizedResponse", () => { + it("should return 401 status", () => { + const response = unauthorizedResponse(); + expect(response.status).toBe(401); + }); + + it("should include WWW-Authenticate header", () => { + const response = unauthorizedResponse(); + expect(response.headers.get("WWW-Authenticate")).toBe('Basic realm="Protected"'); + }); +}); + describe("matchPath", () => { describe("exact match patterns", () => { it("should match exact paths", () => { diff --git a/packages/wildcard/src/utils.ts b/packages/wildcard/src/utils.ts index 8de5df7e..3a5f7d0f 100644 --- a/packages/wildcard/src/utils.ts +++ b/packages/wildcard/src/utils.ts @@ -236,6 +236,40 @@ export function isValidSubdomain(subdomain: string): boolean { return true; } +/** + * Checks if a request has valid HTTP Basic Auth credentials + * Any username is accepted; only the password is validated. + */ +export function checkBasicAuth(request: Request, expectedPassword: string): boolean { + const authorization = request.headers.get("Authorization"); + if (!authorization || !authorization.startsWith("Basic ")) { + return false; + } + + try { + const encoded = authorization.slice("Basic ".length); + const decoded = atob(encoded); + const colonIndex = decoded.indexOf(":"); + if (colonIndex === -1) { + return false; + } + const password = decoded.slice(colonIndex + 1); + return password === expectedPassword; + } catch { + return false; + } +} + +/** + * Returns a 401 Unauthorized response that triggers the browser's Basic Auth dialog + */ +export function unauthorizedResponse(): Response { + return new Response("Unauthorized", { + status: 401, + headers: { "WWW-Authenticate": 'Basic realm="Protected"' }, + }); +} + /** * Timeout for proxy requests in milliseconds */ diff --git a/services/wildcard/src/index.ts b/services/wildcard/src/index.ts index 6e281643..a86130c2 100644 --- a/services/wildcard/src/index.ts +++ b/services/wildcard/src/index.ts @@ -4,6 +4,8 @@ import { handleRedirect, handleRewrite, isValidSubdomain, + checkBasicAuth, + unauthorizedResponse, } from "@just-be/wildcard"; import { z } from "zod"; import { createR2FileLoader, createKVRouteConfigLoader, type Env } from "./adapters"; @@ -118,6 +120,18 @@ export default { const config = result.data; + // Check password protection + if (config.password) { + const expectedPassword = (env as Record)[config.password]; + if (typeof expectedPassword !== "string" || !expectedPassword) { + console.error("Password secret not configured:", { subdomain, secret: config.password }); + return new Response("Service configuration error", { status: 500 }); + } + if (!checkBasicAuth(request, expectedPassword)) { + return unauthorizedResponse(); + } + } + try { let response: Response; From 35d613db1ffd86f1a76007c62f74ee0613b626b4 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Sat, 21 Mar 2026 16:23:55 -0400 Subject: [PATCH 2/2] chore: bump package versions for password protection feature - @just-be/wildcard: 0.6.0 -> 0.7.0 - @just-be/deploy: 0.10.0 -> 0.11.0 - @just-be/wildcard-service: 1.0.0 -> 1.1.0 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/deploy/package.json | 2 +- packages/wildcard/package.json | 2 +- services/wildcard/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/deploy/package.json b/packages/deploy/package.json index d52af912..b9eab707 100644 --- a/packages/deploy/package.json +++ b/packages/deploy/package.json @@ -1,6 +1,6 @@ { "name": "@just-be/deploy", - "version": "0.10.0", + "version": "0.11.0", "description": "Deploy static sites to Cloudflare R2 with subdomain routing", "keywords": [ "cloudflare", diff --git a/packages/wildcard/package.json b/packages/wildcard/package.json index 845103bf..e26fa5cf 100644 --- a/packages/wildcard/package.json +++ b/packages/wildcard/package.json @@ -1,6 +1,6 @@ { "name": "@just-be/wildcard", - "version": "0.6.0", + "version": "0.7.0", "description": "Portable wildcard subdomain routing with pluggable storage backends", "keywords": [ "proxy", diff --git a/services/wildcard/package.json b/services/wildcard/package.json index fa2d458a..ad0633af 100644 --- a/services/wildcard/package.json +++ b/services/wildcard/package.json @@ -1,6 +1,6 @@ { "name": "@just-be/wildcard-service", - "version": "1.0.0", + "version": "1.1.0", "private": true, "type": "module", "scripts": {