@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 +172 -0
- package/package.json +34 -0
- package/src/handlers/index.ts +3 -0
- package/src/handlers/redirect.ts +7 -0
- package/src/handlers/rewrite.ts +64 -0
- package/src/handlers/static.ts +119 -0
- package/src/index.ts +5 -0
- package/src/schemas.ts +89 -0
- package/src/types.ts +51 -0
- package/src/utils.ts +198 -0
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,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
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
|
+
}
|