Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion packages/deploy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand Down Expand Up @@ -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", [
Expand Down Expand Up @@ -364,6 +367,52 @@ async function validateKVAccess(): Promise<boolean> {
}
}

/**
* List secrets configured on the wildcard worker
*/
async function listWorkerSecrets(): Promise<string[]> {
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<typeof spinner>,
): Promise<void> {
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
*/
Expand Down Expand Up @@ -487,13 +536,19 @@ async function deployStaticRule(rule: StaticRule, s: ReturnType<typeof spinner>)

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",
...(rule.spa && { spa: rule.spa }),
...(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`);
Expand All @@ -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`);
Expand All @@ -532,10 +593,16 @@ async function deployRewriteRule(rule: RewriteRule, s: ReturnType<typeof spinner
console.log(` Target URL: ${rule.url}`);
console.log(` Allowed Methods: ${rule.allowedMethods?.join(", ") || "GET, HEAD, OPTIONS"}`);

// Ensure password secret exists if configured
if (rule.password) {
await ensureSecretExists(rule.password, s);
}

const routeConfig: RouteConfig = {
type: "rewrite",
url: rule.url,
...(rule.allowedMethods && { allowedMethods: rule.allowedMethods }),
...(rule.password && { password: rule.password }),
};

s.start(`Creating KV routing entry`);
Expand Down
2 changes: 1 addition & 1 deletion packages/deploy/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
12 changes: 12 additions & 0 deletions packages/deploy/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@
"dir": {
"type": "string",
"minLength": 1
},
"password": {
"type": "string",
"description": "Name of the Cloudflare Worker secret containing the password for HTTP Basic Auth protection"
}
},
"required": ["type", "subdomain", "dir"],
Expand All @@ -102,6 +106,10 @@
},
"subdomain": {
"type": "string"
},
"password": {
"type": "string",
"description": "Name of the Cloudflare Worker secret containing the password for HTTP Basic Auth protection"
}
},
"required": ["type", "url", "subdomain"],
Expand All @@ -128,6 +136,10 @@
},
"subdomain": {
"type": "string"
},
"password": {
"type": "string",
"description": "Name of the Cloudflare Worker secret containing the password for HTTP Basic Auth protection"
}
},
"required": ["type", "url", "allowedMethods", "subdomain"],
Expand Down
2 changes: 1 addition & 1 deletion packages/wildcard/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/wildcard/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const StaticConfigSchema = z
fallback: z.string().optional(), // Fallback file path for non-SPA mode (e.g., "404.html")
redirects: z.array(PathRedirectSchema).optional(),
rewrites: z.array(PathRewriteSchema).optional(),
password: z.string().optional(), // Name of the Worker secret containing the password for HTTP Basic Auth
})
.refine((data) => (data.spa && data.fallback ? false : true), {
message: "fallback cannot be used with spa mode (spa: true)",
Expand All @@ -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", [
Expand Down
64 changes: 64 additions & 0 deletions packages/wildcard/src/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
filterSafeHeaders,
isValidSubdomain,
matchPath,
checkBasicAuth,
unauthorizedResponse,
SAFE_REQUEST_HEADERS,
} from "./utils";

Expand Down Expand Up @@ -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", () => {
Expand Down
34 changes: 34 additions & 0 deletions packages/wildcard/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
2 changes: 1 addition & 1 deletion services/wildcard/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@just-be/wildcard-service",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
14 changes: 14 additions & 0 deletions services/wildcard/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -118,6 +120,18 @@ export default {

const config = result.data;

// Check password protection
if (config.password) {
const expectedPassword = (env as Record<string, unknown>)[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;

Expand Down