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/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": { 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;