@just-be/wildcard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,172 @@
1
+ # @just-be/wildcard
2
+
3
+ Portable wildcard subdomain routing library with pluggable storage backends. Route subdomains to static files, redirects, or proxied URLs with dependency injection for maximum flexibility.
4
+
5
+ ## Features
6
+
7
+ - 🔌 **Pluggable storage backends** - Use any file storage (R2, S3, filesystem, etc.)
8
+ - 🔒 **Security built-in** - SSRF protection, path traversal prevention, safe header filtering
9
+ - 📦 **Three routing modes**:
10
+ - **Static** - Serve files from any storage backend with SPA mode and custom 404s
11
+ - **Redirect** - 301/302 redirects to external URLs
12
+ - **Rewrite** - Proxy requests to external services
13
+ - 🏗️ **Framework-agnostic** - Works with Cloudflare Workers, Node.js, Deno, Bun, etc.
14
+ - ✅ **Type-safe** - Full TypeScript support with Zod validation
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ bun add @just-be/wildcard zod
20
+ ```
21
+
22
+ ## Quick Start
23
+
24
+ ### 1. Implement Storage Adapters
25
+
26
+ ```ts
27
+ import type { FileLoader, RouteConfigLoader } from "@just-be/wildcard";
28
+
29
+ // Implement your file loader (example with filesystem)
30
+ const fileLoader: FileLoader = {
31
+ async loadFile(path: string) {
32
+ try {
33
+ const content = await fs.readFile(path);
34
+ return {
35
+ body: content,
36
+ etag: generateEtag(content),
37
+ headers: new Headers(),
38
+ };
39
+ } catch {
40
+ return null;
41
+ }
42
+ },
43
+ };
44
+
45
+ // Implement your route config loader (example with JSON file)
46
+ const routeConfigLoader: RouteConfigLoader = {
47
+ async loadRouteConfig(subdomain: string) {
48
+ try {
49
+ const config = await fs.readFile(`./config/${subdomain}.json`, "utf-8");
50
+ return config;
51
+ } catch {
52
+ return null;
53
+ }
54
+ },
55
+ };
56
+ ```
57
+
58
+ ### 2. Use the Handlers
59
+
60
+ ```ts
61
+ import { handleStatic, handleRedirect, handleRewrite, StaticConfigSchema } from "@just-be/wildcard";
62
+
63
+ // Load and validate config
64
+ const configJson = await routeConfigLoader.loadRouteConfig("myapp");
65
+ const config = StaticConfigSchema.parse(JSON.parse(configJson));
66
+
67
+ // Handle the request - pass dependencies after config
68
+ const response = await handleStatic(request, config, fileLoader);
69
+
70
+ // Handlers without dependencies don't need extra args
71
+ const redirectResponse = await handleRedirect(request, redirectConfig);
72
+ ```
73
+
74
+ ## Configuration Schemas
75
+
76
+ ### Static File Serving
77
+
78
+ ```ts
79
+ import { StaticConfigSchema } from "@just-be/wildcard";
80
+
81
+ const config = {
82
+ type: "static",
83
+ path: "apps/myapp", // Base path in storage
84
+ spa: true, // Optional: SPA mode (all routes serve index.html)
85
+ fallback: "404.html", // Optional: Custom 404 (only in non-SPA mode)
86
+ };
87
+ ```
88
+
89
+ ### Redirect
90
+
91
+ ```ts
92
+ import { RedirectConfigSchema } from "@just-be/wildcard";
93
+
94
+ const config = {
95
+ type: "redirect",
96
+ url: "https://example.com",
97
+ permanent: false, // Optional: 301 vs 302
98
+ };
99
+ ```
100
+
101
+ ### Rewrite (Proxy)
102
+
103
+ ```ts
104
+ import { RewriteConfigSchema } from "@just-be/wildcard";
105
+
106
+ const config = {
107
+ type: "rewrite",
108
+ url: "https://api.example.com",
109
+ allowedMethods: ["GET", "POST"], // Optional: defaults to ["GET", "HEAD", "OPTIONS"]
110
+ };
111
+ ```
112
+
113
+ ## Dependency Injection
114
+
115
+ The library uses dependency injection to decouple from specific storage backends:
116
+
117
+ ```ts
118
+ interface FileLoader {
119
+ loadFile(path: string): Promise<FileObject | null>;
120
+ }
121
+
122
+ interface RouteConfigLoader {
123
+ loadRouteConfig(subdomain: string): Promise<string | null>;
124
+ }
125
+
126
+ interface FileObject {
127
+ body: ReadableStream<Uint8Array> | ArrayBuffer | null;
128
+ headers?: Headers;
129
+ etag?: string;
130
+ size?: number;
131
+ }
132
+ ```
133
+
134
+ ## Security Features
135
+
136
+ - **SSRF Protection** - Blocks requests to private IPs and localhost
137
+ - **Path Traversal Prevention** - Sanitizes all file paths
138
+ - **Header Filtering** - Only forwards safe headers in proxy mode
139
+ - **Content Type Detection** - Sets correct MIME types
140
+ - **Security Headers** - Adds CSP, X-Frame-Options, etc.
141
+
142
+ ## Example: Cloudflare Workers
143
+
144
+ See the `services/wildcard` directory for a complete Cloudflare Workers implementation using R2 and KV.
145
+
146
+ ## API Reference
147
+
148
+ ### Handlers
149
+
150
+ - `handleStatic(request, config, fileLoader)` - Serve static files from storage
151
+ - `handleRedirect(request, config)` - Handle URL redirects (301/302)
152
+ - `handleRewrite(request, config)` - Proxy requests to external services
153
+
154
+ ### Schemas
155
+
156
+ - `StaticConfigSchema` - Validates static file config
157
+ - `RedirectConfigSchema` - Validates redirect config
158
+ - `RewriteConfigSchema` - Validates rewrite config
159
+ - `RouteConfigSchema` - Discriminated union of all configs
160
+ - `R2ConfigSchema` - (Deprecated) Alias for StaticConfigSchema
161
+
162
+ ### Utilities
163
+
164
+ - `sanitizePath(path)` - Sanitize file paths
165
+ - `isSafeURL(url)` - Validate URLs for SSRF
166
+ - `getContentType(path)` - Detect MIME types
167
+ - `filterSafeHeaders(headers)` - Filter request headers
168
+ - `isValidSubdomain(subdomain)` - Validate subdomain format
169
+
170
+ ## License
171
+
172
+ MIT
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@just-be/wildcard",
3
+ "version": "0.1.0",
4
+ "description": "Portable wildcard subdomain routing with pluggable storage backends",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./schemas": "./src/schemas.ts",
10
+ "./handlers": "./src/handlers/index.ts",
11
+ "./types": "./src/types.ts"
12
+ },
13
+ "files": [
14
+ "src"
15
+ ],
16
+ "keywords": [
17
+ "wildcard",
18
+ "subdomain",
19
+ "routing",
20
+ "static-site",
21
+ "redirect",
22
+ "proxy"
23
+ ],
24
+ "author": "Justin Bennett",
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/justbejyk/just-be.dev.git",
29
+ "directory": "packages/wildcard"
30
+ },
31
+ "peerDependencies": {
32
+ "zod": "^3.0.0"
33
+ }
34
+ }
@@ -0,0 +1,3 @@
1
+ export { handleStatic } from "./static";
2
+ export { handleRedirect } from "./redirect";
3
+ export { handleRewrite } from "./rewrite";
@@ -0,0 +1,7 @@
1
+ import type { Handler } from "../types";
2
+ import type { RedirectConfig } from "../schemas";
3
+
4
+ export const handleRedirect: Handler<RedirectConfig> = async (_request, config) => {
5
+ const status = config.permanent ? 301 : 302;
6
+ return Response.redirect(config.url, status);
7
+ };
@@ -0,0 +1,64 @@
1
+ import type { Handler } from "../types";
2
+ import type { RewriteConfig } from "../schemas";
3
+ import { filterSafeHeaders } from "../utils";
4
+
5
+ const FETCH_TIMEOUT_MS = 5_000;
6
+
7
+ export const handleRewrite: Handler<RewriteConfig> = async (request, config) => {
8
+ const originalUrl = new URL(request.url);
9
+ const { url: targetUrl, allowedMethods } = config;
10
+
11
+ if (!(allowedMethods as string[]).includes(request.method)) {
12
+ return new Response("Method not allowed", {
13
+ status: 405,
14
+ headers: {
15
+ Allow: allowedMethods.join(", "),
16
+ },
17
+ });
18
+ }
19
+
20
+ // Construct the target URL with the original path and query
21
+ const url = new URL(targetUrl);
22
+ url.pathname = originalUrl.pathname;
23
+ url.search = originalUrl.search;
24
+
25
+ // Filter headers to only include safe ones (prevents header injection)
26
+ const safeHeaders = filterSafeHeaders(request.headers);
27
+
28
+ // Create a new request with the target URL and filtered headers
29
+ const modifiedRequest = new Request(url.toString(), {
30
+ method: request.method,
31
+ headers: safeHeaders,
32
+ body: request.method !== "GET" && request.method !== "HEAD" ? request.body : undefined,
33
+ });
34
+
35
+ // Create abort controller for timeout
36
+ const controller = new AbortController();
37
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
38
+
39
+ try {
40
+ const response = await fetch(modifiedRequest, {
41
+ signal: controller.signal,
42
+ });
43
+
44
+ clearTimeout(timeoutId);
45
+
46
+ // Create a new response with the same body but potentially modified headers
47
+ const newResponse = new Response(response.body, response);
48
+
49
+ // Remove content-encoding header because the response body is already decoded
50
+ // by the fetch API, and if we forward this header, the browser will try to
51
+ // decode it again, causing corruption
52
+ newResponse.headers.delete("content-encoding");
53
+
54
+ return newResponse;
55
+ } catch (error) {
56
+ clearTimeout(timeoutId);
57
+
58
+ if (error instanceof Error && error.name === "AbortError") {
59
+ return new Response("Request timeout", { status: 504 });
60
+ }
61
+
62
+ return new Response("Bad gateway", { status: 502 });
63
+ }
64
+ };
@@ -0,0 +1,119 @@
1
+ import type { Handler, FileLoader, FileObject } from "../types";
2
+ import type { StaticConfig } from "../schemas";
3
+ import { getContentType, sanitizePath } from "../utils";
4
+
5
+ export const handleStatic: Handler<StaticConfig, { fileLoader: FileLoader }> = async (
6
+ request,
7
+ config,
8
+ { fileLoader }
9
+ ) => {
10
+ const url = new URL(request.url);
11
+ const { path: basePath, spa, fallback } = config;
12
+
13
+ return spa
14
+ ? handleSpaMode(fileLoader, basePath)
15
+ : handleStaticMode(fileLoader, basePath, url.pathname, fallback);
16
+ };
17
+
18
+ async function handleSpaMode(fileLoader: FileLoader, basePath: string): Promise<Response> {
19
+ // Sanitize base path to prevent directory traversal
20
+ const sanitizedBase = sanitizePath(basePath);
21
+ if (!sanitizedBase) {
22
+ return new Response("Invalid path", { status: 400 });
23
+ }
24
+
25
+ const key = sanitizedBase.endsWith("index.html")
26
+ ? sanitizedBase
27
+ : sanitizedBase.endsWith("/")
28
+ ? `${sanitizedBase}index.html`
29
+ : `${sanitizedBase}/index.html`;
30
+
31
+ const fileObject = await fileLoader.loadFile(key);
32
+
33
+ if (!fileObject) {
34
+ return new Response("File not found", { status: 404 });
35
+ }
36
+
37
+ return buildFileResponse(fileObject, key);
38
+ }
39
+
40
+ async function handleStaticMode(
41
+ fileLoader: FileLoader,
42
+ basePath: string,
43
+ pathname: string,
44
+ fallback?: string
45
+ ): Promise<Response> {
46
+ // Sanitize paths to prevent directory traversal
47
+ const sanitizedBase = sanitizePath(basePath);
48
+ const sanitizedPathname = sanitizePath(pathname);
49
+
50
+ if (!sanitizedBase || !sanitizedPathname) {
51
+ return new Response("Invalid path", { status: 400 });
52
+ }
53
+
54
+ // Standard mode: serve the requested file
55
+ const fullPath = pathname === "/" ? sanitizedBase : `${sanitizedBase}/${sanitizedPathname}`;
56
+ let key = fullPath.replace(/\/+/g, "/"); // Normalize double slashes
57
+
58
+ // Append index.html for directories or extensionless paths
59
+ if (key.endsWith("/") || !key.includes(".")) {
60
+ key = key.endsWith("/") ? `${key}index.html` : `${key}/index.html`;
61
+ }
62
+
63
+ const fileObject = await fileLoader.loadFile(key);
64
+
65
+ if (fileObject) {
66
+ return buildFileResponse(fileObject, key);
67
+ }
68
+
69
+ // Try fallback if configured
70
+ if (fallback) {
71
+ const sanitizedFallback = sanitizePath(fallback);
72
+ if (!sanitizedFallback) {
73
+ return new Response("File not found", { status: 404 });
74
+ }
75
+
76
+ const fallbackKey = `${sanitizedBase}/${sanitizedFallback}`;
77
+ const fallbackObject = await fileLoader.loadFile(fallbackKey);
78
+
79
+ if (fallbackObject) {
80
+ return buildFileResponse(fallbackObject, fallbackKey);
81
+ }
82
+ }
83
+
84
+ return new Response("File not found", { status: 404 });
85
+ }
86
+
87
+ function buildFileResponse(fileObject: FileObject, key: string): Response {
88
+ const headers = new Headers(fileObject.headers);
89
+
90
+ if (fileObject.etag) {
91
+ headers.set("etag", fileObject.etag);
92
+ }
93
+
94
+ if (!headers.has("Content-Type")) {
95
+ const contentType = getContentType(key);
96
+ if (contentType) {
97
+ headers.set("Content-Type", contentType);
98
+ }
99
+ }
100
+
101
+ // Security headers
102
+ headers.set("X-Content-Type-Options", "nosniff");
103
+ headers.set("X-Frame-Options", "SAMEORIGIN");
104
+ headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
105
+
106
+ // Content Security Policy - restrictive default, can be overridden by metadata
107
+ if (!headers.has("Content-Security-Policy")) {
108
+ headers.set(
109
+ "Content-Security-Policy",
110
+ "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';"
111
+ );
112
+ }
113
+
114
+ // Cache control with Vary header for cache poisoning prevention
115
+ headers.set("Cache-Control", "public, max-age=3600");
116
+ headers.set("Vary", "Accept-Encoding");
117
+
118
+ return new Response(fileObject.body, { headers });
119
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ // Re-export everything from submodules
2
+ export * from "./schemas";
3
+ export * from "./types";
4
+ export * from "./handlers";
5
+ export * from "./utils";
package/src/schemas.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Validates that a URL is safe (http/https only, no private IPs)
5
+ *
6
+ * This is a simplified version that checks basic URL structure.
7
+ * For full SSRF protection, use the complete isSafeURL implementation
8
+ * from the wildcard service.
9
+ */
10
+ function isBasicSafeURL(url: string): boolean {
11
+ try {
12
+ const parsed = new URL(url);
13
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ /** Validates that a URL is safe (http/https only, no private IPs) */
20
+ const safeUrl = () =>
21
+ z.string().url().refine(isBasicSafeURL, {
22
+ message: "URL must use http/https and cannot target private/internal addresses",
23
+ });
24
+
25
+ // Zod schemas for validation
26
+ export const StaticConfigSchema = z
27
+ .object({
28
+ type: z.literal("static"),
29
+ path: z.string().min(1),
30
+ spa: z.boolean().optional(),
31
+ fallback: z.string().optional(), // Fallback file path for non-SPA mode (e.g., "404.html")
32
+ })
33
+ .refine((data) => (data.spa && data.fallback ? false : true), {
34
+ message: "fallback cannot be used with spa mode (spa: true)",
35
+ });
36
+
37
+ export const RedirectConfigSchema = z.object({
38
+ type: z.literal("redirect"),
39
+ url: safeUrl(),
40
+ permanent: z.boolean().optional(),
41
+ });
42
+
43
+ const HttpMethod = z.enum(["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]);
44
+
45
+ export const RewriteConfigSchema = z.object({
46
+ type: z.literal("rewrite"),
47
+ url: safeUrl(),
48
+ allowedMethods: z.array(HttpMethod).optional().default(["GET", "HEAD", "OPTIONS"]),
49
+ });
50
+
51
+ export const RouteConfigSchema = z.discriminatedUnion("type", [
52
+ StaticConfigSchema,
53
+ RedirectConfigSchema,
54
+ RewriteConfigSchema,
55
+ ]);
56
+
57
+ /** @deprecated Use StaticConfigSchema instead */
58
+ export const R2ConfigSchema = StaticConfigSchema;
59
+
60
+ /**
61
+ * JSON codec for parsing and validating JSON strings
62
+ */
63
+ const json = <T extends z.ZodType>(schema: T) =>
64
+ z.codec(z.string(), schema, {
65
+ decode: (jsonString, ctx) => {
66
+ try {
67
+ return JSON.parse(jsonString);
68
+ } catch (err: any) {
69
+ ctx.issues.push({
70
+ code: "invalid_format",
71
+ format: "json",
72
+ input: jsonString,
73
+ message: err.message,
74
+ });
75
+ return z.NEVER;
76
+ }
77
+ },
78
+ encode: (value) => JSON.stringify(value),
79
+ });
80
+
81
+ /**
82
+ * Codec for decoding JSON strings into validated RouteConfig objects
83
+ */
84
+ export const RouteConfigCodec = json(RouteConfigSchema);
85
+
86
+ export type StaticConfig = z.infer<typeof StaticConfigSchema>;
87
+ export type RedirectConfig = z.infer<typeof RedirectConfigSchema>;
88
+ export type RewriteConfig = z.infer<typeof RewriteConfigSchema>;
89
+ export type SubdomainConfig = z.infer<typeof RouteConfigSchema>;
package/src/types.ts ADDED
@@ -0,0 +1,51 @@
1
+ import type { StaticConfig, RedirectConfig, RewriteConfig } from "./schemas";
2
+
3
+ /**
4
+ * Represents a file object from any storage backend
5
+ */
6
+ export interface FileObject {
7
+ /** File body as a ReadableStream or ArrayBuffer */
8
+ body: ReadableStream<Uint8Array> | ArrayBuffer | null;
9
+ /** HTTP headers associated with the file */
10
+ headers?: Headers;
11
+ /** ETag for cache validation */
12
+ etag?: string;
13
+ /** Size of the file in bytes */
14
+ size?: number;
15
+ }
16
+
17
+ /**
18
+ * Interface for loading files from a storage backend
19
+ */
20
+ export interface FileLoader {
21
+ /**
22
+ * Load a file from storage by its path
23
+ * @param path - The path to the file
24
+ * @returns The file object, or null if not found
25
+ */
26
+ loadFile(path: string): Promise<FileObject | null>;
27
+ }
28
+
29
+ /**
30
+ * Interface for loading route configurations
31
+ */
32
+ export interface RouteConfigLoader {
33
+ /**
34
+ * Load route configuration for a subdomain
35
+ * @param subdomain - The subdomain name
36
+ * @returns The configuration as a JSON string, or null if not found
37
+ */
38
+ loadRouteConfig(subdomain: string): Promise<string | null>;
39
+ }
40
+
41
+ /**
42
+ * Handler function type with dependency injection
43
+ * @template T - The config type (StaticConfig, RedirectConfig, or RewriteConfig)
44
+ * @template D - Optional dependency object (e.g., { fileLoader: FileLoader })
45
+ */
46
+ export type Handler<
47
+ T extends StaticConfig | RedirectConfig | RewriteConfig,
48
+ D extends Record<string, unknown> = never,
49
+ > = [D] extends [never]
50
+ ? (request: Request, config: T) => Promise<Response>
51
+ : (request: Request, config: T, dependency: D) => Promise<Response>;
package/src/utils.ts ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Content type detection and security utilities
3
+ */
4
+
5
+ export function getContentType(path: string): string | null {
6
+ const ext = path.split(".").pop()?.toLowerCase();
7
+ const types: Record<string, string> = {
8
+ html: "text/html",
9
+ htm: "text/html",
10
+ css: "text/css",
11
+ js: "application/javascript",
12
+ json: "application/json",
13
+ png: "image/png",
14
+ jpg: "image/jpeg",
15
+ jpeg: "image/jpeg",
16
+ gif: "image/gif",
17
+ svg: "image/svg+xml",
18
+ webp: "image/webp",
19
+ ico: "image/x-icon",
20
+ woff: "font/woff",
21
+ woff2: "font/woff2",
22
+ ttf: "font/ttf",
23
+ xml: "application/xml",
24
+ txt: "text/plain",
25
+ };
26
+ return types[ext || ""] || null;
27
+ }
28
+
29
+ /**
30
+ * Security utilities for validating URLs and preventing SSRF attacks
31
+ */
32
+
33
+ /**
34
+ * Checks if an IP address is in a private range
35
+ */
36
+ function isPrivateIP(ip: string): boolean {
37
+ // IPv4 private ranges
38
+ const privateRanges = [
39
+ /^10\./, // 10.0.0.0/8
40
+ /^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12
41
+ /^192\.168\./, // 192.168.0.0/16
42
+ /^127\./, // 127.0.0.0/8 (localhost)
43
+ /^169\.254\./, // 169.254.0.0/16 (link-local, includes cloud metadata)
44
+ /^0\./, // 0.0.0.0/8
45
+ /^::1$/, // IPv6 localhost
46
+ /^fe80:/i, // IPv6 link-local
47
+ /^fc00:/i, // IPv6 unique local
48
+ /^fd00:/i, // IPv6 unique local
49
+ ];
50
+
51
+ return privateRanges.some((range) => range.test(ip));
52
+ }
53
+
54
+ /**
55
+ * Checks if a hostname resolves to localhost or private IPs
56
+ */
57
+ function isSuspiciousHostname(hostname: string): boolean {
58
+ const lowerHostname = hostname.toLowerCase();
59
+
60
+ // Block localhost variants
61
+ if (lowerHostname === "localhost" || lowerHostname === "localhost.localdomain") {
62
+ return true;
63
+ }
64
+
65
+ // Block if hostname is an IP address and it's private
66
+ // IPv4 check
67
+ if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
68
+ return isPrivateIP(hostname);
69
+ }
70
+
71
+ // IPv6 check - URL parser keeps square brackets in hostname
72
+ // Remove brackets for validation
73
+ if (hostname.startsWith("[") && hostname.endsWith("]")) {
74
+ const ipv6 = hostname.slice(1, -1);
75
+ return isPrivateIP(ipv6);
76
+ }
77
+
78
+ return false;
79
+ }
80
+
81
+ /**
82
+ * Validates a URL to prevent SSRF attacks
83
+ * @param url - The URL to validate
84
+ * @returns true if URL is safe, false otherwise
85
+ */
86
+ export function isSafeURL(url: string): boolean {
87
+ try {
88
+ const parsed = new URL(url);
89
+
90
+ // Only allow http and https protocols
91
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
92
+ return false;
93
+ }
94
+
95
+ if (isSuspiciousHostname(parsed.hostname)) {
96
+ return false;
97
+ }
98
+
99
+ return true;
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Sanitizes a file path to prevent directory traversal attacks
107
+ * @param path - The path to sanitize
108
+ * @returns Sanitized path or null if path is invalid
109
+ */
110
+ export function sanitizePath(path: string): string | null {
111
+ // Remove leading slashes
112
+ let normalized = path.replace(/^\/+/, "");
113
+
114
+ // Split into segments
115
+ const segments = normalized.split("/");
116
+
117
+ // Check each segment
118
+ for (const segment of segments) {
119
+ // Reject if segment is .. or . or empty (double slashes)
120
+ if (segment === ".." || segment === "." || segment === "") {
121
+ return null;
122
+ }
123
+
124
+ // Reject if segment contains null bytes or other suspicious characters
125
+ if (segment.includes("\0") || segment.includes("\x00")) {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ // Rebuild the path from clean segments
131
+ return segments.join("/");
132
+ }
133
+
134
+ /**
135
+ * List of safe headers that can be forwarded in proxy requests
136
+ */
137
+ export const SAFE_REQUEST_HEADERS = [
138
+ "accept",
139
+ "accept-language",
140
+ "accept-encoding",
141
+ "cache-control",
142
+ "content-type",
143
+ "user-agent",
144
+ "referer",
145
+ "origin",
146
+ ] as const;
147
+
148
+ /**
149
+ * Filters request headers to only include safe headers
150
+ *
151
+ * @param headers - Original headers
152
+ */
153
+ export function filterSafeHeaders(headers: Headers): Headers {
154
+ const filtered = new Headers();
155
+
156
+ for (const header of SAFE_REQUEST_HEADERS) {
157
+ const value = headers.get(header);
158
+ if (value) {
159
+ filtered.set(header, value);
160
+ }
161
+ }
162
+
163
+ return filtered;
164
+ }
165
+
166
+ /**
167
+ * Validates subdomain name format and length
168
+ * @param subdomain - The subdomain to validate
169
+ * @returns true if subdomain is valid
170
+ */
171
+ export function isValidSubdomain(subdomain: string): boolean {
172
+ // DNS label max length is 63 characters
173
+ if (subdomain.length === 0 || subdomain.length > 63) {
174
+ return false;
175
+ }
176
+
177
+ // Must start and end with alphanumeric
178
+ if (!/^[a-z0-9].*[a-z0-9]$/i.test(subdomain) && subdomain.length > 1) {
179
+ return false;
180
+ }
181
+
182
+ // Single character must be alphanumeric
183
+ if (subdomain.length === 1 && !/^[a-z0-9]$/i.test(subdomain)) {
184
+ return false;
185
+ }
186
+
187
+ // Can only contain alphanumeric and hyphens
188
+ if (!/^[a-z0-9-]+$/i.test(subdomain)) {
189
+ return false;
190
+ }
191
+
192
+ // Cannot have consecutive hyphens
193
+ if (subdomain.includes("--")) {
194
+ return false;
195
+ }
196
+
197
+ return true;
198
+ }