@decocms/runtime 1.0.0-alpha.39 → 1.0.0-alpha.41

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 CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@decocms/runtime",
3
- "version": "1.0.0-alpha.39",
3
+ "version": "1.0.0-alpha.41",
4
4
  "type": "module",
5
5
  "scripts": {
6
- "check": "tsc --noEmit"
6
+ "check": "tsc --noEmit",
7
+ "test": "bun test"
7
8
  },
8
9
  "dependencies": {
9
10
  "@cloudflare/workers-types": "^4.20250617.0",
@@ -25,6 +26,7 @@
25
26
  "./tools": "./src/tools.ts"
26
27
  },
27
28
  "devDependencies": {
29
+ "@types/bun": "^1.3.5",
28
30
  "@types/mime-db": "^1.43.6",
29
31
  "ts-json-schema-generator": "^2.4.0"
30
32
  },
@@ -0,0 +1,306 @@
1
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
2
+ import {
3
+ isPathWithinDirectory,
4
+ resolveAssetPathWithTraversalCheck,
5
+ createAssetHandler,
6
+ } from "./index";
7
+ import { resolve } from "path";
8
+ import { mkdirSync, writeFileSync, rmSync } from "fs";
9
+
10
+ describe("isPathWithinDirectory", () => {
11
+ const baseDir = "/app/client";
12
+
13
+ describe("safe paths", () => {
14
+ test("allows file directly in base directory", () => {
15
+ expect(isPathWithinDirectory("/app/client/index.html", baseDir)).toBe(
16
+ true,
17
+ );
18
+ });
19
+
20
+ test("allows file in subdirectory", () => {
21
+ expect(
22
+ isPathWithinDirectory("/app/client/assets/style.css", baseDir),
23
+ ).toBe(true);
24
+ });
25
+
26
+ test("allows deeply nested file", () => {
27
+ expect(
28
+ isPathWithinDirectory("/app/client/assets/images/logo.png", baseDir),
29
+ ).toBe(true);
30
+ });
31
+
32
+ test("allows base directory itself", () => {
33
+ expect(isPathWithinDirectory("/app/client", baseDir)).toBe(true);
34
+ });
35
+
36
+ test("allows file with spaces in name", () => {
37
+ expect(
38
+ isPathWithinDirectory("/app/client/logos/deco logo.svg", baseDir),
39
+ ).toBe(true);
40
+ });
41
+ });
42
+
43
+ describe("path traversal attacks - BLOCKED", () => {
44
+ test("blocks simple traversal to parent", () => {
45
+ expect(isPathWithinDirectory("/app/style.css", baseDir)).toBe(false);
46
+ });
47
+
48
+ test("blocks traversal to root", () => {
49
+ expect(isPathWithinDirectory("/etc/passwd", baseDir)).toBe(false);
50
+ });
51
+
52
+ test("blocks traversal with ../ sequence", () => {
53
+ const traversalPath = resolve(baseDir, "../../../etc/passwd");
54
+ expect(isPathWithinDirectory(traversalPath, baseDir)).toBe(false);
55
+ });
56
+
57
+ test("blocks traversal to sibling directory", () => {
58
+ expect(isPathWithinDirectory("/app/server/secrets.json", baseDir)).toBe(
59
+ false,
60
+ );
61
+ });
62
+
63
+ test("blocks path that starts with baseDir but is actually sibling", () => {
64
+ // /app/client-secrets is NOT within /app/client
65
+ expect(isPathWithinDirectory("/app/client-secrets/key", baseDir)).toBe(
66
+ false,
67
+ );
68
+ });
69
+
70
+ test("blocks absolute path outside base", () => {
71
+ expect(isPathWithinDirectory("/var/log/system.log", baseDir)).toBe(false);
72
+ });
73
+ });
74
+ });
75
+
76
+ describe("resolveAssetPathWithTraversalCheck", () => {
77
+ const clientDir = "/app/dist/client";
78
+
79
+ // Helper to reduce boilerplate
80
+ const resolvePath = (requestPath: string) =>
81
+ resolveAssetPathWithTraversalCheck({ requestPath, clientDir });
82
+
83
+ describe("valid paths", () => {
84
+ test("resolves root to clientDir", () => {
85
+ expect(resolvePath("/")).toBe(clientDir);
86
+ });
87
+
88
+ test("resolves CSS file", () => {
89
+ expect(resolvePath("/style.css")).toBe("/app/dist/client/style.css");
90
+ });
91
+
92
+ test("resolves nested path", () => {
93
+ expect(resolvePath("/assets/app.js")).toBe(
94
+ "/app/dist/client/assets/app.js",
95
+ );
96
+ });
97
+
98
+ test("resolves path without extension", () => {
99
+ expect(resolvePath("/dashboard")).toBe("/app/dist/client/dashboard");
100
+ });
101
+
102
+ test("resolves path with dots (SPA route)", () => {
103
+ expect(resolvePath("/user/john.doe")).toBe(
104
+ "/app/dist/client/user/john.doe",
105
+ );
106
+ });
107
+
108
+ test("resolves file with spaces", () => {
109
+ expect(resolvePath("/logos/deco logo.svg")).toBe(
110
+ "/app/dist/client/logos/deco logo.svg",
111
+ );
112
+ });
113
+ });
114
+
115
+ describe("path traversal attacks - BLOCKED", () => {
116
+ test("blocks /../../../etc/passwd", () => {
117
+ expect(resolvePath("/../../../etc/passwd")).toBeNull();
118
+ });
119
+
120
+ test("blocks /assets/../../../etc/passwd", () => {
121
+ expect(resolvePath("/assets/../../../etc/passwd")).toBeNull();
122
+ });
123
+
124
+ test("blocks /./../../etc/passwd", () => {
125
+ expect(resolvePath("/./../../etc/passwd")).toBeNull();
126
+ });
127
+
128
+ test("blocks /../etc/passwd", () => {
129
+ expect(resolvePath("/../etc/passwd")).toBeNull();
130
+ });
131
+
132
+ test("allows backslash paths (treated as literal on Unix)", () => {
133
+ // On Unix, backslashes are literal characters - path stays in clientDir
134
+ expect(resolvePath("/..\\..\\etc\\passwd")).not.toBeNull();
135
+ });
136
+
137
+ test("blocks /assets/../../package.json", () => {
138
+ expect(resolvePath("/assets/../../package.json")).toBeNull();
139
+ });
140
+
141
+ test("blocks //etc/passwd (resolves to absolute path)", () => {
142
+ // Double slash after stripping leading / becomes /etc/passwd (absolute)
143
+ expect(resolvePath("//etc/passwd")).toBeNull();
144
+ });
145
+
146
+ test("blocks /valid/../../../etc/passwd", () => {
147
+ expect(resolvePath("/valid/../../../etc/passwd")).toBeNull();
148
+ });
149
+ });
150
+ });
151
+
152
+ describe("createAssetHandler", () => {
153
+ // Temp directory for tests that need real files
154
+ const tempDir = resolve(import.meta.dir, ".test-temp-client");
155
+ const indexContent = "<!DOCTYPE html><html><body>SPA</body></html>";
156
+ const cssContent = "body { color: red; }";
157
+
158
+ beforeAll(() => {
159
+ // Create temp directory with test files
160
+ mkdirSync(resolve(tempDir, "assets"), { recursive: true });
161
+ writeFileSync(resolve(tempDir, "index.html"), indexContent);
162
+ writeFileSync(resolve(tempDir, "assets/style.css"), cssContent);
163
+ });
164
+
165
+ afterAll(() => {
166
+ // Clean up temp directory
167
+ rmSync(tempDir, { recursive: true, force: true });
168
+ });
169
+
170
+ describe("malformed URL encoding", () => {
171
+ test("handles malformed percent-encoded sequences gracefully", async () => {
172
+ // Create handler in production mode to test the decodeURIComponent path
173
+ const handler = createAssetHandler({
174
+ env: "production",
175
+ clientDir: "/app/dist/client",
176
+ });
177
+
178
+ // %E0%A4%A is an incomplete UTF-8 sequence that causes decodeURIComponent to throw
179
+ const malformedUrl = "http://localhost:3000/%E0%A4%A";
180
+ const request = new Request(malformedUrl);
181
+
182
+ // Should return null (graceful fallback) instead of throwing
183
+ const result = await handler(request);
184
+ expect(result).toBeNull();
185
+ });
186
+
187
+ test("handles %FF (invalid UTF-8 byte) gracefully", async () => {
188
+ const handler = createAssetHandler({
189
+ env: "production",
190
+ clientDir: "/app/dist/client",
191
+ });
192
+
193
+ // %FF is not valid in UTF-8
194
+ const malformedUrl = "http://localhost:3000/%FF";
195
+ const request = new Request(malformedUrl);
196
+
197
+ const result = await handler(request);
198
+ expect(result).toBeNull();
199
+ });
200
+
201
+ test("handles truncated multi-byte sequence gracefully", async () => {
202
+ const handler = createAssetHandler({
203
+ env: "production",
204
+ clientDir: "/app/dist/client",
205
+ });
206
+
207
+ // %C2 expects a continuation byte but is truncated
208
+ const malformedUrl = "http://localhost:3000/file%C2.txt";
209
+ const request = new Request(malformedUrl);
210
+
211
+ const result = await handler(request);
212
+ expect(result).toBeNull();
213
+ });
214
+ });
215
+
216
+ describe("SPA fallback for routes with dots", () => {
217
+ test("serves index.html for /user/john.doe (non-existent path with dot)", async () => {
218
+ const handler = createAssetHandler({
219
+ env: "production",
220
+ clientDir: tempDir,
221
+ });
222
+
223
+ const request = new Request("http://localhost:3000/user/john.doe");
224
+ const result = await handler(request);
225
+
226
+ expect(result).not.toBeNull();
227
+ expect(result?.status).toBe(200);
228
+ const text = await result?.text();
229
+ expect(text).toBe(indexContent);
230
+ });
231
+
232
+ test("serves index.html for /page/v2.0 (version-like route)", async () => {
233
+ const handler = createAssetHandler({
234
+ env: "production",
235
+ clientDir: tempDir,
236
+ });
237
+
238
+ const request = new Request("http://localhost:3000/page/v2.0");
239
+ const result = await handler(request);
240
+
241
+ expect(result).not.toBeNull();
242
+ const text = await result?.text();
243
+ expect(text).toBe(indexContent);
244
+ });
245
+
246
+ test("serves index.html for /files/report.2024 (date-like route)", async () => {
247
+ const handler = createAssetHandler({
248
+ env: "production",
249
+ clientDir: tempDir,
250
+ });
251
+
252
+ const request = new Request("http://localhost:3000/files/report.2024");
253
+ const result = await handler(request);
254
+
255
+ expect(result).not.toBeNull();
256
+ const text = await result?.text();
257
+ expect(text).toBe(indexContent);
258
+ });
259
+
260
+ test("serves actual file when it exists", async () => {
261
+ const handler = createAssetHandler({
262
+ env: "production",
263
+ clientDir: tempDir,
264
+ });
265
+
266
+ const request = new Request("http://localhost:3000/assets/style.css");
267
+ const result = await handler(request);
268
+
269
+ expect(result).not.toBeNull();
270
+ expect(result?.status).toBe(200);
271
+ const text = await result?.text();
272
+ expect(text).toBe(cssContent);
273
+ });
274
+
275
+ test("serves index.html for non-existent .css file", async () => {
276
+ const handler = createAssetHandler({
277
+ env: "production",
278
+ clientDir: tempDir,
279
+ });
280
+
281
+ // This CSS file doesn't exist, so it should fall back to index.html
282
+ const request = new Request(
283
+ "http://localhost:3000/assets/nonexistent.css",
284
+ );
285
+ const result = await handler(request);
286
+
287
+ expect(result).not.toBeNull();
288
+ const text = await result?.text();
289
+ expect(text).toBe(indexContent);
290
+ });
291
+
292
+ test("serves index.html for route without dots", async () => {
293
+ const handler = createAssetHandler({
294
+ env: "production",
295
+ clientDir: tempDir,
296
+ });
297
+
298
+ const request = new Request("http://localhost:3000/dashboard");
299
+ const result = await handler(request);
300
+
301
+ expect(result).not.toBeNull();
302
+ const text = await result?.text();
303
+ expect(text).toBe(indexContent);
304
+ });
305
+ });
306
+ });
@@ -1,56 +1,227 @@
1
- import { serveStatic } from "hono/bun";
2
- import { Hono } from "hono";
3
1
  import { devServerProxy } from "./dev-server-proxy";
4
- import { Handler } from "hono/types";
2
+ import { resolve, dirname } from "path";
5
3
 
6
- interface AssetServerConfig {
7
- env: "development" | "production" | "test";
8
- localDevProxyUrl?: string | URL;
4
+ export interface AssetServerConfig {
9
5
  /**
10
- * The prefix to use for serving the assets.
11
- * Default: "/assets/*"
6
+ * Environment mode. Determines whether to proxy to dev server or serve static files.
7
+ * @default process.env.NODE_ENV || "development"
12
8
  */
13
- assetsMiddlewarePath?: string;
9
+ env?: "development" | "production" | "test";
10
+
11
+ /**
12
+ * URL of the Vite dev server for development mode.
13
+ * @default "http://localhost:4000"
14
+ */
15
+ devServerUrl?: string | URL;
16
+
17
+ /**
18
+ * Directory containing the built client assets.
19
+ * For production bundles, use `resolveClientDir()` to get the correct path.
20
+ * @default "./dist/client"
21
+ */
22
+ clientDir?: string;
23
+
14
24
  /**
15
- * The directory to serve the assets from.
16
- * Default: "./dist/client"
25
+ * Function to check if a path should be handled by the API server.
26
+ * Return true for API routes, false for static files.
27
+ * If not provided, defaults to serving everything as static.
17
28
  */
18
- assetsDirectory?: string;
29
+ isServerPath?: (path: string) => boolean;
19
30
  }
20
31
 
21
- const DEFAULT_LOCAL_DEV_PROXY_URL = "http://localhost:4000";
22
- const DEFAULT_ASSETS_DIRECTORY = "./dist/client";
23
- const DEFAULT_ASSETS_MIDDLEWARE_PATH = "/assets/*";
32
+ const DEFAULT_DEV_SERVER_URL = "http://localhost:4000";
33
+ const DEFAULT_CLIENT_DIR = "./dist/client";
34
+
35
+ /**
36
+ * Check if a resolved file path is safely within the allowed base directory.
37
+ * Prevents path traversal attacks (e.g., /../../../etc/passwd).
38
+ *
39
+ * @param filePath - The resolved absolute file path
40
+ * @param baseDir - The base directory that files must be within
41
+ * @returns true if the path is safe, false if it's a traversal attempt
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * isPathWithinDirectory("/app/client/style.css", "/app/client") // true
46
+ * isPathWithinDirectory("/etc/passwd", "/app/client") // false
47
+ * ```
48
+ */
49
+ export function isPathWithinDirectory(
50
+ filePath: string,
51
+ baseDir: string,
52
+ ): boolean {
53
+ const resolvedBase = resolve(baseDir);
54
+ const resolvedPath = resolve(filePath);
55
+ return (
56
+ resolvedPath === resolvedBase || resolvedPath.startsWith(resolvedBase + "/")
57
+ );
58
+ }
59
+
60
+ export interface ResolveAssetPathOptions {
61
+ /** The decoded URL pathname (e.g., "/assets/style.css") */
62
+ requestPath: string;
63
+ /** The base directory containing static files */
64
+ clientDir: string;
65
+ }
66
+
67
+ /**
68
+ * Resolve a URL pathname to a file path, with path traversal protection.
69
+ * Returns null if the path is a traversal attempt.
70
+ *
71
+ * @returns File path, or null if unsafe
72
+ *
73
+ * @example
74
+ * ```ts
75
+ * resolveAssetPathWithTraversalCheck({ requestPath: "/style.css", clientDir: "/app/client" })
76
+ * // "/app/client/style.css"
77
+ *
78
+ * resolveAssetPathWithTraversalCheck({ requestPath: "/../../../etc/passwd", clientDir: "/app/client" })
79
+ * // null (blocked)
80
+ * ```
81
+ */
82
+ export function resolveAssetPathWithTraversalCheck({
83
+ requestPath,
84
+ clientDir,
85
+ }: ResolveAssetPathOptions): string | null {
86
+ const relativePath = requestPath.startsWith("/")
87
+ ? requestPath.slice(1)
88
+ : requestPath;
89
+ const filePath = resolve(clientDir, relativePath);
90
+
91
+ // Security: block path traversal attempts
92
+ if (!isPathWithinDirectory(filePath, clientDir)) {
93
+ return null;
94
+ }
95
+
96
+ return filePath;
97
+ }
24
98
 
25
- interface HonoApp {
26
- use: (path: string, handler: Handler) => void;
27
- get: (path: string, handler: Handler) => void;
99
+ /**
100
+ * Resolve the client directory path relative to the running script.
101
+ * Use this for production bundles where the script location differs from CWD.
102
+ *
103
+ * @param importMetaUrl - Pass `import.meta.url` from the calling module
104
+ * @param relativePath - Path relative to the script (default: "../client")
105
+ * @returns Absolute path to the client directory
106
+ *
107
+ * @example
108
+ * ```ts
109
+ * const clientDir = resolveClientDir(import.meta.url, "../client");
110
+ * ```
111
+ */
112
+ export function resolveClientDir(
113
+ importMetaUrl: string,
114
+ relativePath = "../client",
115
+ ): string {
116
+ const scriptUrl = new URL(importMetaUrl);
117
+ const scriptDir = dirname(scriptUrl.pathname);
118
+ return resolve(scriptDir, relativePath);
28
119
  }
29
120
 
30
- export const applyAssetServerRoutes = (
31
- app: HonoApp,
32
- config: AssetServerConfig,
33
- ) => {
34
- const environment = config.env;
35
- const localDevProxyUrl =
36
- config.localDevProxyUrl ?? DEFAULT_LOCAL_DEV_PROXY_URL;
37
- const assetsDirectory = config.assetsDirectory ?? DEFAULT_ASSETS_DIRECTORY;
38
- const assetsMiddlewarePath =
39
- config.assetsMiddlewarePath ?? DEFAULT_ASSETS_MIDDLEWARE_PATH;
40
-
41
- if (environment === "development") {
42
- app.use("*", devServerProxy(localDevProxyUrl));
43
- } else if (environment === "production") {
44
- app.use(assetsMiddlewarePath, serveStatic({ root: assetsDirectory }));
45
- app.get("*", serveStatic({ path: `${assetsDirectory}/index.html` }));
121
+ /**
122
+ * TODO(@camudo): make "modes" so we can for example try to serve the asset then fallback to api on miss
123
+ * or try to serve the api call then fallback to asset or index.html on 404
124
+ */
125
+
126
+ /**
127
+ * Create an asset handler that works with Bun.serve.
128
+ *
129
+ * In development: Proxies requests to Vite dev server
130
+ * In production: Serves static files with SPA fallback
131
+ *
132
+ * @example
133
+ * ```ts
134
+ * const handleAssets = createAssetHandler({
135
+ * env: process.env.NODE_ENV as "development" | "production",
136
+ * clientDir: resolveClientDir(import.meta.url),
137
+ * isServerPath: (path) => path.startsWith("/api/") || path.startsWith("/mcp/"),
138
+ * });
139
+ *
140
+ * Bun.serve({
141
+ * fetch: async (request) => {
142
+ * return await handleAssets(request) ?? app.fetch(request);
143
+ * },
144
+ * });
145
+ * ```
146
+ */
147
+ export function createAssetHandler(config: AssetServerConfig = {}) {
148
+ const {
149
+ env = (process.env.NODE_ENV as "development" | "production" | "test") ||
150
+ "development",
151
+ devServerUrl = DEFAULT_DEV_SERVER_URL,
152
+ clientDir = DEFAULT_CLIENT_DIR,
153
+ isServerPath = () => false,
154
+ } = config;
155
+
156
+ // Development: Create a proxy handler
157
+ if (env === "development") {
158
+ const proxyHandler = devServerProxy(devServerUrl);
159
+
160
+ return async function handleAssets(
161
+ request: Request,
162
+ ): Promise<Response | null> {
163
+ // In dev, proxy everything except server paths
164
+ const url = new URL(request.url);
165
+ if (isServerPath(url.pathname)) {
166
+ return null;
167
+ }
168
+
169
+ // Create a minimal Hono context for the proxy
170
+ const fakeContext = {
171
+ req: { raw: request, url: request.url },
172
+ };
173
+ return proxyHandler(fakeContext as any);
174
+ };
46
175
  }
47
- };
48
176
 
49
- export const createAssetServer = (config: AssetServerConfig) => {
50
- const app = new Hono();
51
- applyAssetServerRoutes(app, config);
52
- return app;
53
- };
177
+ // Production: Serve static files
178
+ return async function handleAssets(
179
+ request: Request,
180
+ ): Promise<Response | null> {
181
+ // Only handle GET requests
182
+ if (request.method !== "GET") {
183
+ return null;
184
+ }
54
185
 
55
- export const createAssetServerFetcher = (config: AssetServerConfig) =>
56
- createAssetServer(config).fetch;
186
+ const requestUrl = new URL(request.url);
187
+
188
+ // Decode the pathname to handle URL-encoded characters (e.g., %20 -> space)
189
+ // decodeURIComponent can throw URIError for malformed sequences (e.g., %E0%A4%A)
190
+ let path: string;
191
+ try {
192
+ path = decodeURIComponent(requestUrl.pathname);
193
+ } catch {
194
+ // Malformed URL encoding - return null to let API server handle or return 400
195
+ return null;
196
+ }
197
+
198
+ // Let API server handle its routes
199
+ if (isServerPath(path)) {
200
+ return null;
201
+ }
202
+
203
+ // Resolve path with traversal check
204
+ const filePath = resolveAssetPathWithTraversalCheck({
205
+ requestPath: path,
206
+ clientDir,
207
+ });
208
+ if (!filePath) {
209
+ return null; // Path traversal attempt blocked
210
+ }
211
+
212
+ // Try to serve the requested file, fall back to index.html for SPA routing
213
+ const indexPath = resolve(clientDir, "index.html");
214
+ for (const pathToTry of [filePath, indexPath]) {
215
+ try {
216
+ const file = Bun.file(pathToTry);
217
+ if (await file.exists()) {
218
+ return new Response(file);
219
+ }
220
+ } catch {
221
+ // Continue to next path
222
+ }
223
+ }
224
+
225
+ return null;
226
+ };
227
+ }
package/src/oauth.ts CHANGED
@@ -480,11 +480,7 @@ export function createOAuthHandlers(oauth: OAuthConfig) {
480
480
  /**
481
481
  * Check if request has authentication token
482
482
  */
483
- const hasAuth = (req: Request): boolean => {
484
- const authHeader = req.headers.get("Authorization");
485
- const meshToken = req.headers.get("x-mesh-token");
486
- return !!(authHeader || meshToken);
487
- };
483
+ const hasAuth = (req: Request) => req.headers.has("Authorization");
488
484
 
489
485
  return {
490
486
  handleProtectedResourceMetadata,
package/tsconfig.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "extends": "../../tsconfig.json",
3
3
  "compilerOptions": {
4
- "types": ["@cloudflare/workers-types", "@types/node"]
4
+ "types": ["@cloudflare/workers-types", "@types/node", "bun"]
5
5
  },
6
6
  "include": ["src/**/*"],
7
7
  "exclude": ["node_modules", "dist", "scripts/**/*"]