@just-be/wildcard 0.1.0 → 0.2.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 CHANGED
@@ -157,7 +157,6 @@ See the `services/wildcard` directory for a complete Cloudflare Workers implemen
157
157
  - `RedirectConfigSchema` - Validates redirect config
158
158
  - `RewriteConfigSchema` - Validates rewrite config
159
159
  - `RouteConfigSchema` - Discriminated union of all configs
160
- - `R2ConfigSchema` - (Deprecated) Alias for StaticConfigSchema
161
160
 
162
161
  ### Utilities
163
162
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@just-be/wildcard",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Portable wildcard subdomain routing with pluggable storage backends",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -25,10 +25,13 @@
25
25
  "license": "MIT",
26
26
  "repository": {
27
27
  "type": "git",
28
- "url": "https://github.com/justbejyk/just-be.dev.git",
28
+ "url": "git+https://github.com/just-be-dev/just-be.dev.git",
29
29
  "directory": "packages/wildcard"
30
30
  },
31
31
  "peerDependencies": {
32
- "zod": "^3.0.0"
32
+ "zod": "^4.0.0"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
33
36
  }
34
37
  }
@@ -8,7 +8,13 @@ export const handleStatic: Handler<StaticConfig, { fileLoader: FileLoader }> = a
8
8
  { fileLoader }
9
9
  ) => {
10
10
  const url = new URL(request.url);
11
- const { path: basePath, spa, fallback } = config;
11
+ const { spa, fallback } = config;
12
+
13
+ // Extract subdomain from hostname to use as base path
14
+ // e.g., "foo.just-be.dev" -> "foo"
15
+ const hostname = url.hostname;
16
+ const subdomain = hostname.split(".")[0];
17
+ const basePath = subdomain;
12
18
 
13
19
  return spa
14
20
  ? handleSpaMode(fileLoader, basePath)
package/src/schemas.ts CHANGED
@@ -1,32 +1,22 @@
1
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
- }
2
+ import { isSafeURL, isValidSubdomain } from "./utils";
18
3
 
19
4
  /** Validates that a URL is safe (http/https only, no private IPs) */
20
5
  const safeUrl = () =>
21
- z.string().url().refine(isBasicSafeURL, {
6
+ z.url().refine(isSafeURL, {
22
7
  message: "URL must use http/https and cannot target private/internal addresses",
23
8
  });
24
9
 
10
+ /** Validates subdomain format (alphanumeric with hyphens, 1-63 characters) */
11
+ export const subdomain = () =>
12
+ z.string().refine(isValidSubdomain, {
13
+ message: "Invalid subdomain format. Must be alphanumeric with hyphens, 1-63 characters.",
14
+ });
15
+
25
16
  // Zod schemas for validation
26
17
  export const StaticConfigSchema = z
27
18
  .object({
28
19
  type: z.literal("static"),
29
- path: z.string().min(1),
30
20
  spa: z.boolean().optional(),
31
21
  fallback: z.string().optional(), // Fallback file path for non-SPA mode (e.g., "404.html")
32
22
  })
@@ -54,9 +44,6 @@ export const RouteConfigSchema = z.discriminatedUnion("type", [
54
44
  RewriteConfigSchema,
55
45
  ]);
56
46
 
57
- /** @deprecated Use StaticConfigSchema instead */
58
- export const R2ConfigSchema = StaticConfigSchema;
59
-
60
47
  /**
61
48
  * JSON codec for parsing and validating JSON strings
62
49
  */
@@ -86,4 +73,4 @@ export const RouteConfigCodec = json(RouteConfigSchema);
86
73
  export type StaticConfig = z.infer<typeof StaticConfigSchema>;
87
74
  export type RedirectConfig = z.infer<typeof RedirectConfigSchema>;
88
75
  export type RewriteConfig = z.infer<typeof RewriteConfigSchema>;
89
- export type SubdomainConfig = z.infer<typeof RouteConfigSchema>;
76
+ export type RouteConfig = z.infer<typeof RouteConfigSchema>;
@@ -0,0 +1,343 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ getContentType,
4
+ isSafeURL,
5
+ sanitizePath,
6
+ filterSafeHeaders,
7
+ isValidSubdomain,
8
+ SAFE_REQUEST_HEADERS,
9
+ } from "./utils";
10
+
11
+ describe("getContentType", () => {
12
+ it("should return correct MIME types for common file extensions", () => {
13
+ expect(getContentType("index.html")).toBe("text/html");
14
+ expect(getContentType("style.css")).toBe("text/css");
15
+ expect(getContentType("script.js")).toBe("application/javascript");
16
+ expect(getContentType("data.json")).toBe("application/json");
17
+ expect(getContentType("image.png")).toBe("image/png");
18
+ expect(getContentType("photo.jpg")).toBe("image/jpeg");
19
+ expect(getContentType("photo.jpeg")).toBe("image/jpeg");
20
+ expect(getContentType("icon.svg")).toBe("image/svg+xml");
21
+ expect(getContentType("font.woff2")).toBe("font/woff2");
22
+ });
23
+
24
+ it("should handle uppercase extensions", () => {
25
+ expect(getContentType("FILE.HTML")).toBe("text/html");
26
+ expect(getContentType("IMAGE.PNG")).toBe("image/png");
27
+ });
28
+
29
+ it("should handle paths with multiple dots", () => {
30
+ expect(getContentType("file.min.js")).toBe("application/javascript");
31
+ expect(getContentType("archive.tar.gz")).toBe(null);
32
+ });
33
+
34
+ it("should return null for unknown extensions", () => {
35
+ expect(getContentType("file.xyz")).toBe(null);
36
+ expect(getContentType("README")).toBe(null);
37
+ });
38
+
39
+ it("should return null for files without extensions", () => {
40
+ expect(getContentType("Dockerfile")).toBe(null);
41
+ expect(getContentType("Makefile")).toBe(null);
42
+ });
43
+ });
44
+
45
+ describe("isSafeURL", () => {
46
+ describe("valid URLs", () => {
47
+ it("should allow valid http and https URLs", () => {
48
+ expect(isSafeURL("https://example.com")).toBe(true);
49
+ expect(isSafeURL("http://example.com")).toBe(true);
50
+ expect(isSafeURL("https://api.example.com/path?query=value")).toBe(true);
51
+ expect(isSafeURL("https://sub.domain.example.com")).toBe(true);
52
+ });
53
+ });
54
+
55
+ describe("protocol validation", () => {
56
+ it("should reject non-http/https protocols", () => {
57
+ expect(isSafeURL("file:///etc/passwd")).toBe(false);
58
+ expect(isSafeURL("ftp://example.com")).toBe(false);
59
+ expect(isSafeURL("javascript:alert(1)")).toBe(false);
60
+ expect(isSafeURL("data:text/html,<script>alert(1)</script>")).toBe(false);
61
+ expect(isSafeURL("gopher://example.com")).toBe(false);
62
+ });
63
+ });
64
+
65
+ describe("SSRF protection - localhost", () => {
66
+ it("should block localhost", () => {
67
+ expect(isSafeURL("http://localhost")).toBe(false);
68
+ expect(isSafeURL("http://localhost:8080")).toBe(false);
69
+ expect(isSafeURL("http://LOCALHOST")).toBe(false);
70
+ expect(isSafeURL("http://localhost.localdomain")).toBe(false);
71
+ });
72
+
73
+ it("should block 127.x.x.x (loopback)", () => {
74
+ expect(isSafeURL("http://127.0.0.1")).toBe(false);
75
+ expect(isSafeURL("http://127.0.0.1:8080")).toBe(false);
76
+ expect(isSafeURL("http://127.1.1.1")).toBe(false);
77
+ expect(isSafeURL("http://127.255.255.255")).toBe(false);
78
+ });
79
+
80
+ it("should block IPv6 localhost", () => {
81
+ expect(isSafeURL("http://[::1]")).toBe(false);
82
+ expect(isSafeURL("http://[::1]:8080")).toBe(false);
83
+ });
84
+ });
85
+
86
+ describe("SSRF protection - private IP ranges", () => {
87
+ it("should block 10.x.x.x (private class A)", () => {
88
+ expect(isSafeURL("http://10.0.0.1")).toBe(false);
89
+ expect(isSafeURL("http://10.1.2.3")).toBe(false);
90
+ expect(isSafeURL("http://10.255.255.255")).toBe(false);
91
+ });
92
+
93
+ it("should block 192.168.x.x (private class C)", () => {
94
+ expect(isSafeURL("http://192.168.0.1")).toBe(false);
95
+ expect(isSafeURL("http://192.168.1.1")).toBe(false);
96
+ expect(isSafeURL("http://192.168.255.255")).toBe(false);
97
+ });
98
+
99
+ it("should block 172.16-31.x.x (private class B)", () => {
100
+ expect(isSafeURL("http://172.16.0.1")).toBe(false);
101
+ expect(isSafeURL("http://172.20.10.5")).toBe(false);
102
+ expect(isSafeURL("http://172.31.255.255")).toBe(false);
103
+ });
104
+
105
+ it("should allow non-private 172.x.x.x addresses", () => {
106
+ expect(isSafeURL("http://172.15.0.1")).toBe(true);
107
+ expect(isSafeURL("http://172.32.0.1")).toBe(true);
108
+ });
109
+ });
110
+
111
+ describe("SSRF protection - cloud metadata endpoints", () => {
112
+ it("should block 169.254.x.x (link-local/metadata)", () => {
113
+ expect(isSafeURL("http://169.254.169.254")).toBe(false);
114
+ expect(isSafeURL("http://169.254.169.254/latest/meta-data")).toBe(false);
115
+ expect(isSafeURL("http://169.254.1.1")).toBe(false);
116
+ });
117
+ });
118
+
119
+ describe("SSRF protection - other restricted ranges", () => {
120
+ it("should block 0.0.0.0/8", () => {
121
+ expect(isSafeURL("http://0.0.0.0")).toBe(false);
122
+ expect(isSafeURL("http://0.1.2.3")).toBe(false);
123
+ });
124
+
125
+ it("should block IPv6 link-local (fe80::)", () => {
126
+ expect(isSafeURL("http://[fe80::1]")).toBe(false);
127
+ expect(isSafeURL("http://[fe80:0000:0000:0000:0000:0000:0000:0001]")).toBe(false);
128
+ });
129
+
130
+ it("should block IPv6 unique local (fc00::/7)", () => {
131
+ expect(isSafeURL("http://[fc00::1]")).toBe(false);
132
+ expect(isSafeURL("http://[fd00::1]")).toBe(false);
133
+ });
134
+ });
135
+
136
+ describe("invalid URLs", () => {
137
+ it("should return false for malformed URLs", () => {
138
+ expect(isSafeURL("not a url")).toBe(false);
139
+ expect(isSafeURL("")).toBe(false);
140
+ expect(isSafeURL("htp://example.com")).toBe(false);
141
+ });
142
+ });
143
+ });
144
+
145
+ describe("sanitizePath", () => {
146
+ describe("valid paths", () => {
147
+ it("should allow and sanitize simple paths", () => {
148
+ expect(sanitizePath("file.txt")).toBe("file.txt");
149
+ expect(sanitizePath("dir/file.txt")).toBe("dir/file.txt");
150
+ expect(sanitizePath("a/b/c/file.txt")).toBe("a/b/c/file.txt");
151
+ });
152
+
153
+ it("should remove leading slashes", () => {
154
+ expect(sanitizePath("/file.txt")).toBe("file.txt");
155
+ expect(sanitizePath("//file.txt")).toBe("file.txt");
156
+ expect(sanitizePath("///path/to/file.txt")).toBe("path/to/file.txt");
157
+ });
158
+ });
159
+
160
+ describe("directory traversal protection", () => {
161
+ it("should reject paths with ..", () => {
162
+ expect(sanitizePath("../file.txt")).toBe(null);
163
+ expect(sanitizePath("dir/../file.txt")).toBe(null);
164
+ expect(sanitizePath("../../etc/passwd")).toBe(null);
165
+ expect(sanitizePath("dir/../../file.txt")).toBe(null);
166
+ });
167
+
168
+ it("should reject paths with .", () => {
169
+ expect(sanitizePath("./file.txt")).toBe(null);
170
+ expect(sanitizePath("dir/./file.txt")).toBe(null);
171
+ expect(sanitizePath(".")).toBe(null);
172
+ });
173
+
174
+ it("should reject paths with double slashes (empty segments)", () => {
175
+ expect(sanitizePath("dir//file.txt")).toBe(null);
176
+ expect(sanitizePath("a/b//c")).toBe(null);
177
+ });
178
+ });
179
+
180
+ describe("null byte protection", () => {
181
+ it("should reject paths with null bytes", () => {
182
+ expect(sanitizePath("file\0.txt")).toBe(null);
183
+ expect(sanitizePath("dir/file\x00.txt")).toBe(null);
184
+ });
185
+ });
186
+
187
+ describe("edge cases", () => {
188
+ it("should handle paths with hyphens and underscores", () => {
189
+ expect(sanitizePath("my-file_name.txt")).toBe("my-file_name.txt");
190
+ expect(sanitizePath("some-dir/my_file.txt")).toBe("some-dir/my_file.txt");
191
+ });
192
+
193
+ it("should handle paths with spaces", () => {
194
+ expect(sanitizePath("my file.txt")).toBe("my file.txt");
195
+ expect(sanitizePath("some dir/my file.txt")).toBe("some dir/my file.txt");
196
+ });
197
+ });
198
+ });
199
+
200
+ describe("filterSafeHeaders", () => {
201
+ it("should filter and keep only safe headers", () => {
202
+ const headers = new Headers({
203
+ accept: "application/json",
204
+ "user-agent": "Mozilla/5.0",
205
+ authorization: "Bearer secret-token",
206
+ cookie: "session=abc123",
207
+ "x-forwarded-for": "1.2.3.4",
208
+ });
209
+
210
+ const filtered = filterSafeHeaders(headers);
211
+
212
+ expect(filtered.get("accept")).toBe("application/json");
213
+ expect(filtered.get("user-agent")).toBe("Mozilla/5.0");
214
+ expect(filtered.get("authorization")).toBe(null);
215
+ expect(filtered.get("cookie")).toBe(null);
216
+ expect(filtered.get("x-forwarded-for")).toBe(null);
217
+ });
218
+
219
+ it("should include all safe headers that are present", () => {
220
+ const headers = new Headers({
221
+ accept: "text/html",
222
+ "accept-language": "en-US",
223
+ "accept-encoding": "gzip",
224
+ "cache-control": "no-cache",
225
+ "content-type": "application/json",
226
+ "user-agent": "Test",
227
+ referer: "https://example.com",
228
+ origin: "https://example.com",
229
+ });
230
+
231
+ const filtered = filterSafeHeaders(headers);
232
+
233
+ for (const header of SAFE_REQUEST_HEADERS) {
234
+ expect(filtered.has(header)).toBe(true);
235
+ }
236
+ });
237
+
238
+ it("should return empty headers when no safe headers present", () => {
239
+ const headers = new Headers({
240
+ authorization: "Bearer token",
241
+ cookie: "session=abc",
242
+ "x-custom-header": "value",
243
+ });
244
+
245
+ const filtered = filterSafeHeaders(headers);
246
+
247
+ expect([...filtered.keys()].length).toBe(0);
248
+ });
249
+
250
+ it("should be case-insensitive for header names", () => {
251
+ const headers = new Headers({
252
+ Accept: "application/json",
253
+ "USER-AGENT": "Test",
254
+ "Content-Type": "text/plain",
255
+ });
256
+
257
+ const filtered = filterSafeHeaders(headers);
258
+
259
+ expect(filtered.get("accept")).toBe("application/json");
260
+ expect(filtered.get("user-agent")).toBe("Test");
261
+ expect(filtered.get("content-type")).toBe("text/plain");
262
+ });
263
+ });
264
+
265
+ describe("isValidSubdomain", () => {
266
+ describe("valid subdomains", () => {
267
+ it("should allow single alphanumeric character", () => {
268
+ expect(isValidSubdomain("a")).toBe(true);
269
+ expect(isValidSubdomain("z")).toBe(true);
270
+ expect(isValidSubdomain("0")).toBe(true);
271
+ expect(isValidSubdomain("9")).toBe(true);
272
+ });
273
+
274
+ it("should allow alphanumeric subdomains", () => {
275
+ expect(isValidSubdomain("test")).toBe(true);
276
+ expect(isValidSubdomain("api")).toBe(true);
277
+ expect(isValidSubdomain("www")).toBe(true);
278
+ expect(isValidSubdomain("app123")).toBe(true);
279
+ expect(isValidSubdomain("123app")).toBe(true);
280
+ });
281
+
282
+ it("should allow hyphens in the middle", () => {
283
+ expect(isValidSubdomain("my-app")).toBe(true);
284
+ expect(isValidSubdomain("test-api-v2")).toBe(true);
285
+ expect(isValidSubdomain("a-b-c")).toBe(true);
286
+ });
287
+
288
+ it("should allow mixed case (validation is case-insensitive)", () => {
289
+ expect(isValidSubdomain("MyApp")).toBe(true);
290
+ expect(isValidSubdomain("TEST")).toBe(true);
291
+ });
292
+
293
+ it("should allow up to 63 characters", () => {
294
+ const maxLength = "a".repeat(63);
295
+ expect(isValidSubdomain(maxLength)).toBe(true);
296
+ });
297
+ });
298
+
299
+ describe("invalid subdomains", () => {
300
+ it("should reject empty string", () => {
301
+ expect(isValidSubdomain("")).toBe(false);
302
+ });
303
+
304
+ it("should reject subdomains longer than 63 characters", () => {
305
+ const tooLong = "a".repeat(64);
306
+ expect(isValidSubdomain(tooLong)).toBe(false);
307
+ });
308
+
309
+ it("should reject subdomains starting with hyphen", () => {
310
+ expect(isValidSubdomain("-test")).toBe(false);
311
+ expect(isValidSubdomain("-")).toBe(false);
312
+ });
313
+
314
+ it("should reject subdomains ending with hyphen", () => {
315
+ expect(isValidSubdomain("test-")).toBe(false);
316
+ expect(isValidSubdomain("a-")).toBe(false);
317
+ });
318
+
319
+ it("should reject subdomains with consecutive hyphens", () => {
320
+ expect(isValidSubdomain("test--app")).toBe(false);
321
+ expect(isValidSubdomain("a--b")).toBe(false);
322
+ expect(isValidSubdomain("---")).toBe(false);
323
+ });
324
+
325
+ it("should reject subdomains with special characters", () => {
326
+ expect(isValidSubdomain("test_app")).toBe(false);
327
+ expect(isValidSubdomain("test.app")).toBe(false);
328
+ expect(isValidSubdomain("test@app")).toBe(false);
329
+ expect(isValidSubdomain("test/app")).toBe(false);
330
+ expect(isValidSubdomain("test app")).toBe(false);
331
+ });
332
+
333
+ it("should reject single character hyphens", () => {
334
+ expect(isValidSubdomain("-")).toBe(false);
335
+ });
336
+
337
+ it("should reject single character special chars", () => {
338
+ expect(isValidSubdomain("_")).toBe(false);
339
+ expect(isValidSubdomain(".")).toBe(false);
340
+ expect(isValidSubdomain("@")).toBe(false);
341
+ });
342
+ });
343
+ });