@just-be/wildcard 0.5.0 → 0.7.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/package.json +19 -19
- package/src/handlers/path-rules.ts +1 -1
- package/src/handlers/redirect.test.ts +3 -3
- package/src/handlers/static.ts +22 -12
- package/src/schemas.ts +3 -0
- package/src/utils.test.ts +64 -0
- package/src/utils.ts +35 -4
package/package.json
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@just-be/wildcard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
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
5
|
"keywords": [
|
|
17
|
-
"
|
|
18
|
-
"
|
|
6
|
+
"proxy",
|
|
7
|
+
"redirect",
|
|
19
8
|
"routing",
|
|
20
9
|
"static-site",
|
|
21
|
-
"
|
|
22
|
-
"
|
|
10
|
+
"subdomain",
|
|
11
|
+
"wildcard"
|
|
23
12
|
],
|
|
24
|
-
"author": "Justin Bennett",
|
|
25
13
|
"license": "MIT",
|
|
14
|
+
"author": "Justin Bennett",
|
|
26
15
|
"repository": {
|
|
27
16
|
"type": "git",
|
|
28
17
|
"url": "git+https://github.com/just-be-dev/just-be.dev.git",
|
|
29
18
|
"directory": "packages/wildcard"
|
|
30
19
|
},
|
|
31
|
-
"
|
|
32
|
-
"
|
|
20
|
+
"files": [
|
|
21
|
+
"src"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"main": "./src/index.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": "./src/index.ts",
|
|
27
|
+
"./schemas": "./src/schemas.ts",
|
|
28
|
+
"./handlers": "./src/handlers/index.ts",
|
|
29
|
+
"./types": "./src/types.ts"
|
|
33
30
|
},
|
|
34
31
|
"publishConfig": {
|
|
35
32
|
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"zod": "^4.0.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
|
@@ -7,7 +7,7 @@ import { matchPath, proxyRequest } from "../utils";
|
|
|
7
7
|
*/
|
|
8
8
|
export async function handlePathRules(
|
|
9
9
|
request: Request,
|
|
10
|
-
config: StaticConfig
|
|
10
|
+
config: StaticConfig,
|
|
11
11
|
): Promise<Response | null> {
|
|
12
12
|
const url = new URL(request.url);
|
|
13
13
|
const pathname = url.pathname;
|
|
@@ -56,10 +56,10 @@ describe("handleRedirect", () => {
|
|
|
56
56
|
};
|
|
57
57
|
const response = await handleRedirect(
|
|
58
58
|
makeRequest("https://git.just-be.dev/pds?tab=readme"),
|
|
59
|
-
config
|
|
59
|
+
config,
|
|
60
60
|
);
|
|
61
61
|
expect(response.headers.get("location")).toBe(
|
|
62
|
-
"https://github.com/just-be-dev/pds?tab=readme"
|
|
62
|
+
"https://github.com/just-be-dev/pds?tab=readme",
|
|
63
63
|
);
|
|
64
64
|
});
|
|
65
65
|
|
|
@@ -90,7 +90,7 @@ describe("handleRedirect", () => {
|
|
|
90
90
|
};
|
|
91
91
|
const response = await handleRedirect(
|
|
92
92
|
makeRequest("https://sub.just-be.dev/some/path"),
|
|
93
|
-
config
|
|
93
|
+
config,
|
|
94
94
|
);
|
|
95
95
|
expect(response.headers.get("location")).toBe("https://example.com");
|
|
96
96
|
});
|
package/src/handlers/static.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { handlePathRules } from "./path-rules";
|
|
|
6
6
|
export const handleStatic: Handler<StaticConfig, { fileLoader: FileLoader }> = async (
|
|
7
7
|
request,
|
|
8
8
|
config,
|
|
9
|
-
{ fileLoader }
|
|
9
|
+
{ fileLoader },
|
|
10
10
|
) => {
|
|
11
11
|
// Check path-level redirects and rewrites first
|
|
12
12
|
const pathResponse = await handlePathRules(request, config);
|
|
@@ -22,14 +22,15 @@ export const handleStatic: Handler<StaticConfig, { fileLoader: FileLoader }> = a
|
|
|
22
22
|
const basePath = subdomain;
|
|
23
23
|
|
|
24
24
|
return spa
|
|
25
|
-
? handleSpaMode(fileLoader, basePath, url.pathname)
|
|
26
|
-
: handleStaticMode(fileLoader, basePath, url.pathname, fallback);
|
|
25
|
+
? handleSpaMode(fileLoader, basePath, url.pathname, request)
|
|
26
|
+
: handleStaticMode(fileLoader, basePath, url.pathname, request, fallback);
|
|
27
27
|
};
|
|
28
28
|
|
|
29
29
|
async function handleSpaMode(
|
|
30
30
|
fileLoader: FileLoader,
|
|
31
31
|
basePath: string,
|
|
32
|
-
pathname: string
|
|
32
|
+
pathname: string,
|
|
33
|
+
request: Request,
|
|
33
34
|
): Promise<Response> {
|
|
34
35
|
// Sanitize paths to prevent directory traversal
|
|
35
36
|
const sanitizedBase = sanitizePath(basePath);
|
|
@@ -53,7 +54,7 @@ async function handleSpaMode(
|
|
|
53
54
|
const fileObject = await fileLoader.loadFile(key);
|
|
54
55
|
|
|
55
56
|
if (fileObject) {
|
|
56
|
-
return buildFileResponse(fileObject, key);
|
|
57
|
+
return buildFileResponse(fileObject, key, request);
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
60
|
|
|
@@ -70,14 +71,15 @@ async function handleSpaMode(
|
|
|
70
71
|
return new Response("File not found", { status: 404 });
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
return buildFileResponse(indexFile, indexKey);
|
|
74
|
+
return buildFileResponse(indexFile, indexKey, request);
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
async function handleStaticMode(
|
|
77
78
|
fileLoader: FileLoader,
|
|
78
79
|
basePath: string,
|
|
79
80
|
pathname: string,
|
|
80
|
-
|
|
81
|
+
request: Request,
|
|
82
|
+
fallback?: string,
|
|
81
83
|
): Promise<Response> {
|
|
82
84
|
// Sanitize paths to prevent directory traversal
|
|
83
85
|
const sanitizedBase = sanitizePath(basePath);
|
|
@@ -99,7 +101,7 @@ async function handleStaticMode(
|
|
|
99
101
|
const fileObject = await fileLoader.loadFile(key);
|
|
100
102
|
|
|
101
103
|
if (fileObject) {
|
|
102
|
-
return buildFileResponse(fileObject, key);
|
|
104
|
+
return buildFileResponse(fileObject, key, request);
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
// Try fallback if configured
|
|
@@ -113,14 +115,14 @@ async function handleStaticMode(
|
|
|
113
115
|
const fallbackObject = await fileLoader.loadFile(fallbackKey);
|
|
114
116
|
|
|
115
117
|
if (fallbackObject) {
|
|
116
|
-
return buildFileResponse(fallbackObject, fallbackKey);
|
|
118
|
+
return buildFileResponse(fallbackObject, fallbackKey, request);
|
|
117
119
|
}
|
|
118
120
|
}
|
|
119
121
|
|
|
120
122
|
return new Response("File not found", { status: 404 });
|
|
121
123
|
}
|
|
122
124
|
|
|
123
|
-
function buildFileResponse(fileObject: FileObject, key: string): Response {
|
|
125
|
+
function buildFileResponse(fileObject: FileObject, key: string, request: Request): Response {
|
|
124
126
|
const headers = new Headers(fileObject.headers);
|
|
125
127
|
|
|
126
128
|
if (fileObject.etag) {
|
|
@@ -143,13 +145,21 @@ function buildFileResponse(fileObject: FileObject, key: string): Response {
|
|
|
143
145
|
if (!headers.has("Content-Security-Policy")) {
|
|
144
146
|
headers.set(
|
|
145
147
|
"Content-Security-Policy",
|
|
146
|
-
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';"
|
|
148
|
+
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';",
|
|
147
149
|
);
|
|
148
150
|
}
|
|
149
151
|
|
|
150
152
|
// Cache control with Vary header for cache poisoning prevention
|
|
151
|
-
headers.set("Cache-Control", "public, max-age=3600");
|
|
153
|
+
headers.set("Cache-Control", "public, max-age=3600"); // 1 hour browser cache
|
|
154
|
+
headers.set("CDN-Cache-Control", "public, max-age=604800"); // 7 days edge cache
|
|
152
155
|
headers.set("Vary", "Accept-Encoding");
|
|
153
156
|
|
|
157
|
+
// Handle conditional requests (If-None-Match)
|
|
158
|
+
const ifNoneMatch = request.headers.get("If-None-Match");
|
|
159
|
+
if (ifNoneMatch && fileObject.etag && ifNoneMatch === fileObject.etag) {
|
|
160
|
+
// Return 304 Not Modified with headers but no body
|
|
161
|
+
return new Response(null, { status: 304, headers });
|
|
162
|
+
}
|
|
163
|
+
|
|
154
164
|
return new Response(fileObject.body, { headers });
|
|
155
165
|
}
|
package/src/schemas.ts
CHANGED
|
@@ -37,6 +37,7 @@ export const StaticConfigSchema = z
|
|
|
37
37
|
fallback: z.string().optional(), // Fallback file path for non-SPA mode (e.g., "404.html")
|
|
38
38
|
redirects: z.array(PathRedirectSchema).optional(),
|
|
39
39
|
rewrites: z.array(PathRewriteSchema).optional(),
|
|
40
|
+
password: z.string().optional(), // Name of the Worker secret containing the password for HTTP Basic Auth
|
|
40
41
|
})
|
|
41
42
|
.refine((data) => (data.spa && data.fallback ? false : true), {
|
|
42
43
|
message: "fallback cannot be used with spa mode (spa: true)",
|
|
@@ -47,12 +48,14 @@ export const RedirectConfigSchema = z.object({
|
|
|
47
48
|
url: safeUrl(),
|
|
48
49
|
permanent: z.boolean().optional(),
|
|
49
50
|
preservePath: z.boolean().optional(),
|
|
51
|
+
password: z.string().optional(),
|
|
50
52
|
});
|
|
51
53
|
|
|
52
54
|
export const RewriteConfigSchema = z.object({
|
|
53
55
|
type: z.literal("rewrite"),
|
|
54
56
|
url: safeUrl(),
|
|
55
57
|
allowedMethods: z.array(HttpMethod).optional().default(["GET", "HEAD", "OPTIONS"]),
|
|
58
|
+
password: z.string().optional(),
|
|
56
59
|
});
|
|
57
60
|
|
|
58
61
|
export const RouteConfigSchema = z.discriminatedUnion("type", [
|
package/src/utils.test.ts
CHANGED
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
filterSafeHeaders,
|
|
7
7
|
isValidSubdomain,
|
|
8
8
|
matchPath,
|
|
9
|
+
checkBasicAuth,
|
|
10
|
+
unauthorizedResponse,
|
|
9
11
|
SAFE_REQUEST_HEADERS,
|
|
10
12
|
} from "./utils";
|
|
11
13
|
|
|
@@ -343,6 +345,68 @@ describe("isValidSubdomain", () => {
|
|
|
343
345
|
});
|
|
344
346
|
});
|
|
345
347
|
|
|
348
|
+
describe("checkBasicAuth", () => {
|
|
349
|
+
function requestWithAuth(username: string, password: string): Request {
|
|
350
|
+
const encoded = btoa(`${username}:${password}`);
|
|
351
|
+
return new Request("https://example.com", {
|
|
352
|
+
headers: { Authorization: `Basic ${encoded}` },
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
it("should return true for correct password with any username", () => {
|
|
357
|
+
expect(checkBasicAuth(requestWithAuth("user", "secret"), "secret")).toBe(true);
|
|
358
|
+
expect(checkBasicAuth(requestWithAuth("admin", "secret"), "secret")).toBe(true);
|
|
359
|
+
expect(checkBasicAuth(requestWithAuth("", "secret"), "secret")).toBe(true);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("should return false for wrong password", () => {
|
|
363
|
+
expect(checkBasicAuth(requestWithAuth("user", "wrong"), "secret")).toBe(false);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("should return false when no Authorization header", () => {
|
|
367
|
+
const request = new Request("https://example.com");
|
|
368
|
+
expect(checkBasicAuth(request, "secret")).toBe(false);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("should return false for non-Basic auth schemes", () => {
|
|
372
|
+
const request = new Request("https://example.com", {
|
|
373
|
+
headers: { Authorization: "Bearer token123" },
|
|
374
|
+
});
|
|
375
|
+
expect(checkBasicAuth(request, "secret")).toBe(false);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("should return false for malformed base64", () => {
|
|
379
|
+
const request = new Request("https://example.com", {
|
|
380
|
+
headers: { Authorization: "Basic !!!invalid!!!" },
|
|
381
|
+
});
|
|
382
|
+
expect(checkBasicAuth(request, "secret")).toBe(false);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("should return false for base64 without colon separator", () => {
|
|
386
|
+
const encoded = btoa("nocolon");
|
|
387
|
+
const request = new Request("https://example.com", {
|
|
388
|
+
headers: { Authorization: `Basic ${encoded}` },
|
|
389
|
+
});
|
|
390
|
+
expect(checkBasicAuth(request, "nocolon")).toBe(false);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("should handle passwords containing colons", () => {
|
|
394
|
+
expect(checkBasicAuth(requestWithAuth("user", "pass:word:here"), "pass:word:here")).toBe(true);
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
describe("unauthorizedResponse", () => {
|
|
399
|
+
it("should return 401 status", () => {
|
|
400
|
+
const response = unauthorizedResponse();
|
|
401
|
+
expect(response.status).toBe(401);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("should include WWW-Authenticate header", () => {
|
|
405
|
+
const response = unauthorizedResponse();
|
|
406
|
+
expect(response.headers.get("WWW-Authenticate")).toBe('Basic realm="Protected"');
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
346
410
|
describe("matchPath", () => {
|
|
347
411
|
describe("exact match patterns", () => {
|
|
348
412
|
it("should match exact paths", () => {
|
package/src/utils.ts
CHANGED
|
@@ -236,6 +236,40 @@ export function isValidSubdomain(subdomain: string): boolean {
|
|
|
236
236
|
return true;
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Checks if a request has valid HTTP Basic Auth credentials
|
|
241
|
+
* Any username is accepted; only the password is validated.
|
|
242
|
+
*/
|
|
243
|
+
export function checkBasicAuth(request: Request, expectedPassword: string): boolean {
|
|
244
|
+
const authorization = request.headers.get("Authorization");
|
|
245
|
+
if (!authorization || !authorization.startsWith("Basic ")) {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
const encoded = authorization.slice("Basic ".length);
|
|
251
|
+
const decoded = atob(encoded);
|
|
252
|
+
const colonIndex = decoded.indexOf(":");
|
|
253
|
+
if (colonIndex === -1) {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
const password = decoded.slice(colonIndex + 1);
|
|
257
|
+
return password === expectedPassword;
|
|
258
|
+
} catch {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Returns a 401 Unauthorized response that triggers the browser's Basic Auth dialog
|
|
265
|
+
*/
|
|
266
|
+
export function unauthorizedResponse(): Response {
|
|
267
|
+
return new Response("Unauthorized", {
|
|
268
|
+
status: 401,
|
|
269
|
+
headers: { "WWW-Authenticate": 'Basic realm="Protected"' },
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
239
273
|
/**
|
|
240
274
|
* Timeout for proxy requests in milliseconds
|
|
241
275
|
*/
|
|
@@ -248,10 +282,7 @@ const FETCH_TIMEOUT_MS = 5_000;
|
|
|
248
282
|
* @param targetUrl - The target URL to proxy to
|
|
249
283
|
* @returns Response from the proxied request
|
|
250
284
|
*/
|
|
251
|
-
export async function proxyRequest(
|
|
252
|
-
request: Request,
|
|
253
|
-
targetUrl: URL
|
|
254
|
-
): Promise<Response> {
|
|
285
|
+
export async function proxyRequest(request: Request, targetUrl: URL): Promise<Response> {
|
|
255
286
|
// Filter headers to only include safe ones (prevents header injection)
|
|
256
287
|
const safeHeaders = filterSafeHeaders(request.headers);
|
|
257
288
|
|