kempo-server 1.7.0 → 1.7.2

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
@@ -213,6 +213,11 @@ export default async function(request, response) {
213
213
 
214
214
  To configure the server, create a configuration file (by default `.config.json`) within the root directory of your server (`public` in the start example [above](#getting-started)). You can specify a different configuration file using the `--config` flag.
215
215
 
216
+ **Important:**
217
+ - When using a relative path for the `--config` flag, the config file must be located within the server root directory
218
+ - When using an absolute path for the `--config` flag, the config file can be located anywhere on the filesystem
219
+ - The server will throw an error if you attempt to use a relative config file path that points outside the root directory
220
+
216
221
  This json file can have any of the following properties, any property not defined will use the "Default Config".
217
222
 
218
223
  - [allowedMimes](#allowedmimes)
@@ -558,35 +563,41 @@ An array of regex patterns for paths that should not trigger a file system resca
558
563
 
559
564
  An object mapping custom route paths to file paths. Useful for aliasing or serving files from outside the document root.
560
565
 
566
+ **Note:** All file paths in customRoutes are resolved relative to the server root directory (the `--root` path). This allows you to reference files both inside and outside the document root.
567
+
561
568
  **Basic Routes:**
562
569
  ```json
563
570
  {
564
571
  "customRoutes": {
565
- "/vendor/bootstrap.css": "./node_modules/bootstrap/dist/css/bootstrap.min.css",
572
+ "/vendor/bootstrap.css": "../node_modules/bootstrap/dist/css/bootstrap.min.css",
566
573
  "/api/status": "./status.js"
567
574
  }
568
575
  }
569
576
  ```
570
577
 
571
578
  **Wildcard Routes:**
572
- Wildcard routes allow you to map entire directory structures using the `*` wildcard:
579
+ Wildcard routes allow you to map entire directory structures using the `*` and `**` wildcards:
573
580
 
574
581
  ```json
575
582
  {
576
583
  "customRoutes": {
577
- "kempo/*": "./node_modules/kempo/dust/*",
578
- "assets/*": "./static-files/*",
579
- "docs/*": "./documentation/*"
584
+ "kempo/*": "../node_modules/kempo/dist/*",
585
+ "assets/*": "../static-files/*",
586
+ "docs/*": "../documentation/*",
587
+ "src/**": "../src/**"
580
588
  }
581
589
  }
582
590
  ```
583
591
 
584
592
  With wildcard routes:
585
- - `kempo/styles.css` would serve `./node_modules/kempo/dust/styles.css`
586
- - `assets/logo.png` would serve `./static-files/logo.png`
587
- - `docs/readme.md` would serve `./documentation/readme.md`
588
-
589
- The `*` wildcard matches any single path segment (anything between `/` characters). Multiple wildcards can be used in a single route pattern.
593
+ - `kempo/styles.css` would serve `../node_modules/kempo/dist/styles.css`
594
+ - `assets/logo.png` would serve `../static-files/logo.png`
595
+ - `docs/readme.md` would serve `../documentation/readme.md`
596
+ - `src/components/Button.js` would serve `../src/components/Button.js`
597
+
598
+ The `*` wildcard matches any single path segment (anything between `/` characters).
599
+ The `**` wildcard matches any number of path segments, allowing you to map entire directory trees.
600
+ Multiple wildcards can be used in a single route pattern.
590
601
 
591
602
  ### maxRescanAttempts
592
603
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kempo-server",
3
3
  "type": "module",
4
- "version": "1.7.0",
4
+ "version": "1.7.2",
5
5
  "description": "A lightweight, zero-dependency, file based routing server.",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
@@ -88,8 +88,8 @@ export default {
88
88
  ],
89
89
  maxRescanAttempts: 3,
90
90
  customRoutes: {
91
- // Example: "/vendor/bootstrap.css": "./node_modules/bootstrap/dist/css/bootstrap.min.css"
92
- // Wildcard example: "kempo/*": "./node_modules/kempo/dust/*"
91
+ // Example: "/vendor/bootstrap.css": "../node_modules/bootstrap/dist/css/bootstrap.min.css"
92
+ // Wildcard example: "kempo/*": "../node_modules/kempo/dust/*"
93
93
  },
94
94
  middleware: {
95
95
  // Built-in middleware configuration
package/src/router.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
 
18
18
  export default async (flags, log) => {
19
19
  log('Initializing router', 2);
20
- const rootPath = path.join(process.cwd(), flags.root);
20
+ const rootPath = path.isAbsolute(flags.root) ? flags.root : path.join(process.cwd(), flags.root);
21
21
  log(`Root path: ${rootPath}`, 2);
22
22
 
23
23
  let config = defaultConfig;
@@ -27,6 +27,25 @@ export default async (flags, log) => {
27
27
  const configPath = path.isAbsolute(configFileName)
28
28
  ? configFileName
29
29
  : path.join(rootPath, configFileName);
30
+
31
+ log(`Config file name: ${configFileName}`, 2);
32
+ log(`Config path: ${configPath}`, 2);
33
+
34
+ // Validate that config file is within the server root directory
35
+ // Allow absolute paths (user explicitly specified location)
36
+ if (!path.isAbsolute(configFileName)) {
37
+ const relativeConfigPath = path.relative(rootPath, configPath);
38
+ log(`Relative config path: ${relativeConfigPath}`, 2);
39
+ log(`Starts with '..': ${relativeConfigPath.startsWith('..')}`, 2);
40
+ if (relativeConfigPath.startsWith('..') || path.isAbsolute(relativeConfigPath)) {
41
+ log(`Validation failed - throwing error`, 2);
42
+ throw new Error(`Config file must be within the server root directory. Config path: ${configPath}, Root path: ${rootPath}`);
43
+ }
44
+ log(`Validation passed`, 2);
45
+ } else {
46
+ log(`Config file name is absolute, skipping validation`, 2);
47
+ }
48
+
30
49
  log(`Loading config from: ${configPath}`, 2);
31
50
  const configContent = await readFile(configPath, 'utf8');
32
51
  const userConfig = JSON.parse(configContent);
@@ -53,6 +72,11 @@ export default async (flags, log) => {
53
72
  };
54
73
  log('User config loaded and merged with defaults', 2);
55
74
  } catch (e){
75
+ // Only fall back to default config for file reading/parsing errors
76
+ // Let validation errors propagate up
77
+ if (e.message.includes('Config file must be within the server root directory')) {
78
+ throw e;
79
+ }
56
80
  log('Using default config (no config file found)', 2);
57
81
  }
58
82
 
@@ -136,27 +160,19 @@ export default async (flags, log) => {
136
160
 
137
161
  if (config.customRoutes && Object.keys(config.customRoutes).length > 0) {
138
162
  log(`Processing ${Object.keys(config.customRoutes).length} custom routes`, 2);
139
-
140
163
  for (const [urlPath, filePath] of Object.entries(config.customRoutes)) {
141
- try {
142
- // Check if this is a wildcard route
143
- if (urlPath.includes('*')) {
144
- // Store wildcard routes separately for pattern matching
145
- wildcardRoutes.set(urlPath, filePath);
146
- log(`Wildcard route mapped: ${urlPath} -> ${filePath}`, 2);
147
- } else {
148
- // Resolve the file path relative to the current working directory
149
- const resolvedPath = path.resolve(filePath);
150
-
151
- // Check if the file exists (we'll do this async)
152
- const { stat } = await import('fs/promises');
153
- await stat(resolvedPath);
154
-
155
- customRoutes.set(urlPath, resolvedPath);
156
- log(`Custom route mapped: ${urlPath} -> ${resolvedPath}`, 2);
157
- }
158
- } catch (error) {
159
- log(`Custom route error for ${urlPath} -> ${filePath}: ${error.message}`, 1);
164
+ // Check if this is a wildcard route
165
+ if (urlPath.includes('*')) {
166
+ // Resolve the file path relative to rootPath if relative, otherwise use absolute path
167
+ const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(rootPath, filePath);
168
+ // Store wildcard routes separately for pattern matching
169
+ wildcardRoutes.set(urlPath, resolvedPath);
170
+ log(`Wildcard route mapped: ${urlPath} -> ${resolvedPath}`, 2);
171
+ } else {
172
+ // Resolve the file path relative to rootPath if relative, otherwise use absolute path
173
+ const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(rootPath, filePath);
174
+ customRoutes.set(urlPath, resolvedPath);
175
+ log(`Custom route mapped: ${urlPath} -> ${resolvedPath}`, 2);
160
176
  }
161
177
  }
162
178
  }
@@ -187,7 +203,9 @@ export default async (flags, log) => {
187
203
  }
188
204
  }
189
205
 
190
- return path.resolve(resolvedPath);
206
+ // If the path is already absolute, return it as-is
207
+ // If it's relative, resolve it relative to rootPath
208
+ return path.isAbsolute(resolvedPath) ? resolvedPath : path.resolve(rootPath, resolvedPath);
191
209
  };
192
210
 
193
211
  // Helper function to find matching wildcard route
@@ -247,39 +265,63 @@ export default async (flags, log) => {
247
265
  const requestPath = req.url.split('?')[0];
248
266
  log(`${req.method} ${requestPath}`, 0);
249
267
 
250
- // Check custom routes first
251
- if (customRoutes.has(requestPath)) {
252
- const customFilePath = customRoutes.get(requestPath);
253
- log(`Serving custom route: ${requestPath} -> ${customFilePath}`, 2);
254
-
268
+
269
+ // Check custom routes first (allow outside rootPath)
270
+ log(`customRoutes keys: ${Array.from(customRoutes.keys()).join(', ')}`, 1);
271
+ // Normalize requestPath and keys for matching
272
+ const normalizePath = p => {
273
+ let np = decodeURIComponent(p);
274
+ if (!np.startsWith('/')) np = '/' + np;
275
+ if (np.length > 1 && np.endsWith('/')) np = np.slice(0, -1);
276
+ return np;
277
+ };
278
+ const normalizedRequestPath = normalizePath(requestPath);
279
+ log(`Normalized requestPath: ${normalizedRequestPath}`, 1);
280
+ let matchedKey = null;
281
+ for (const key of customRoutes.keys()) {
282
+ if (normalizePath(key) === normalizedRequestPath) {
283
+ matchedKey = key;
284
+ break;
285
+ }
286
+ }
287
+ if (matchedKey) {
288
+ const customFilePath = customRoutes.get(matchedKey);
289
+ log(`Serving custom route: ${normalizedRequestPath} -> ${customFilePath}`, 2);
255
290
  try {
291
+ const { stat } = await import('fs/promises');
292
+ try {
293
+ await stat(customFilePath);
294
+ log(`Custom route file exists: ${customFilePath}`, 2);
295
+ } catch (e) {
296
+ log(`Custom route file does NOT exist: ${customFilePath}`, 0);
297
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
298
+ res.end('Custom route file not found');
299
+ return;
300
+ }
256
301
  const fileContent = await readFile(customFilePath);
257
302
  const fileExtension = path.extname(customFilePath).toLowerCase().slice(1);
258
303
  const mimeType = config.allowedMimes[fileExtension] || 'application/octet-stream';
259
-
260
304
  log(`Serving custom file as ${mimeType} (${fileContent.length} bytes)`, 2);
261
305
  res.writeHead(200, { 'Content-Type': mimeType });
262
306
  res.end(fileContent);
263
307
  return; // Successfully served custom route
264
308
  } catch (error) {
265
- log(`Error serving custom route ${requestPath}: ${error.message}`, 0);
309
+ log(`Error serving custom route ${normalizedRequestPath}: ${error.message}`, 0);
266
310
  res.writeHead(500, { 'Content-Type': 'text/plain' });
267
311
  res.end('Internal Server Error');
268
312
  return;
269
313
  }
270
314
  }
271
-
272
- // Check wildcard routes
315
+
316
+ // Check wildcard routes (allow outside rootPath)
273
317
  const wildcardMatch = findWildcardRoute(requestPath);
274
318
  if (wildcardMatch) {
275
319
  const resolvedFilePath = resolveWildcardPath(wildcardMatch.filePath, wildcardMatch.matches);
276
320
  log(`Serving wildcard route: ${requestPath} -> ${resolvedFilePath}`, 2);
277
-
278
321
  try {
279
322
  const fileContent = await readFile(resolvedFilePath);
280
323
  const fileExtension = path.extname(resolvedFilePath).toLowerCase().slice(1);
281
324
  const mimeType = config.allowedMimes[fileExtension] || 'application/octet-stream';
282
-
283
325
  log(`Serving wildcard file as ${mimeType} (${fileContent.length} bytes)`, 2);
284
326
  res.writeHead(200, { 'Content-Type': mimeType });
285
327
  res.end(fileContent);
@@ -317,5 +317,56 @@ export default {
317
317
  process.chdir(prev);
318
318
  }
319
319
  });
320
+ },
321
+
322
+ 'router throws error when config file is outside server root': async ({pass, fail, log}) => {
323
+ await withTempDir(async (dir) => {
324
+ // Create a config file outside the server root
325
+ const configDir = path.join(dir, '..', 'config-outside-root');
326
+ const configFilePath = await write(configDir, 'outside.config.json', '{"allowedMimes": {"test": "application/test"}}');
327
+
328
+ // Create a file in the server root to verify it doesn't start
329
+ await write(dir, 'index.html', '<h1>Home</h1>');
330
+
331
+ const prev = process.cwd();
332
+ process.chdir(dir);
333
+
334
+ try {
335
+ // Try to use config file outside server root with relative path
336
+ const flags = {root: '.', logging: 0, scan: false, config: '../config-outside-root/outside.config.json'};
337
+
338
+ log('Test setup:');
339
+ log('dir: ' + dir);
340
+ log('configDir: ' + configDir);
341
+ log('configFilePath: ' + configFilePath);
342
+ log('flags.root: ' + flags.root);
343
+ log('flags.config: ' + flags.config);
344
+
345
+ // Check if file exists
346
+ const fs = await import('fs/promises');
347
+ try {
348
+ await fs.access(configFilePath);
349
+ log('Config file exists at: ' + configFilePath);
350
+ } catch (e) {
351
+ log('Config file does NOT exist at: ' + configFilePath);
352
+ }
353
+
354
+ // This should throw an error
355
+ await router(flags, log);
356
+
357
+ // If we reach here, the test failed
358
+ return fail('router should have thrown error for config file outside root');
359
+ } catch (error) {
360
+ log('Error caught: ' + error.message);
361
+ // Verify the error message contains expected text
362
+ if (!error.message.includes('Config file must be within the server root directory')) {
363
+ return fail(`unexpected error message: ${error.message}`);
364
+ }
365
+
366
+ pass('router correctly throws error for config file outside server root');
367
+ } finally {
368
+ process.chdir(prev);
369
+ }
370
+ });
320
371
  }
321
372
  };
@@ -0,0 +1,81 @@
1
+ import router from '../src/router.js';
2
+ import http from 'http';
3
+ import { writeFile, mkdir } from 'fs/promises';
4
+ import path from 'path';
5
+ import { randomPort, httpGet, withTempDir, write } from './test-utils.js';
6
+
7
+ /*
8
+ This test verifies that a customRoute pointing outside the rootPath is served
9
+ instead of a static file inside the rootPath, if both exist.
10
+ */
11
+
12
+ export default {
13
+ 'customRoute outside rootPath takes precedence over static file': async ({pass, fail, log}) => {
14
+ try {
15
+ await withTempDir(async (dir) => {
16
+ // Print initial working directory
17
+ log('Initial process.cwd(): ' + process.cwd());
18
+
19
+ // Setup: create static file in rootPath
20
+ const rootDir = path.join(dir, 'public');
21
+ await mkdir(path.join(rootDir, 'src'), { recursive: true });
22
+ await writeFile(path.join(rootDir, 'src', 'file.txt'), 'static');
23
+ // Create custom file outside rootPath, matching resolved customRoute
24
+ const customFilePath = path.resolve(rootDir, '..', 'src', 'file.txt');
25
+ await mkdir(path.dirname(customFilePath), { recursive: true });
26
+ await writeFile(customFilePath, 'custom');
27
+ log('Custom file path: ' + customFilePath);
28
+
29
+ // Check file existence
30
+ try {
31
+ const { stat } = await import('fs/promises');
32
+ await stat(customFilePath);
33
+ log('Custom file exists at setup');
34
+ } catch (e) {
35
+ log('Custom file does NOT exist at setup');
36
+ }
37
+
38
+ // Write config with customRoute pointing outside rootPath
39
+ const config = {
40
+ customRoutes: {
41
+ '/src/file.txt': '../src/file.txt'
42
+ }
43
+ };
44
+ await writeFile(path.join(rootDir, '.config.json'), JSON.stringify(config));
45
+ log('Config written: ' + JSON.stringify(config));
46
+
47
+ // Set working directory to temp dir so relative paths resolve correctly
48
+ const prevCwd = process.cwd();
49
+ process.chdir(dir);
50
+ const flags = { root: rootDir, logging: 4 };
51
+ const logFn = (...args) => log(args.map(String).join(' '));
52
+ log('Starting server with flags: ' + JSON.stringify(flags));
53
+ log('process.cwd() before router: ' + process.cwd());
54
+ const handler = await router(flags, logFn);
55
+ const server = http.createServer(handler);
56
+ const port = randomPort();
57
+ await new Promise(r => server.listen(port, r));
58
+ await new Promise(r => setTimeout(r, 50));
59
+
60
+ try {
61
+ // Print process.cwd() before request
62
+ log('process.cwd() before request: ' + process.cwd());
63
+ // Request the file
64
+ const requestUrl = `http://localhost:${port}/src/file.txt`;
65
+ log('Requesting URL: ' + requestUrl);
66
+ const response = await httpGet(requestUrl);
67
+ log('status: ' + response.res.statusCode);
68
+ log('response body: ' + response.body.toString());
69
+ if(response.res.statusCode !== 200) throw new Error('status not 200');
70
+ if(response.body.toString() !== 'custom') throw new Error('did not serve customRoute file');
71
+ } finally {
72
+ server.close();
73
+ process.chdir(prevCwd);
74
+ }
75
+ });
76
+ pass('customRoute outside rootPath is served');
77
+ } catch(e){
78
+ fail(e.message);
79
+ }
80
+ }
81
+ };
@@ -25,7 +25,7 @@ export default {
25
25
  // Configure double asterisk wildcard route
26
26
  const config = {
27
27
  customRoutes: {
28
- '/src/**': './src/**'
28
+ '/src/**': '../src/**'
29
29
  }
30
30
  };
31
31
 
@@ -86,7 +86,7 @@ export default {
86
86
  // Configure single asterisk wildcard route
87
87
  const config = {
88
88
  customRoutes: {
89
- '/src/*': './src/*'
89
+ '/src/*': '../src/*'
90
90
  }
91
91
  };
92
92
 
@@ -140,7 +140,7 @@ export default {
140
140
  // Configure wildcard route that overrides static file
141
141
  const config = {
142
142
  customRoutes: {
143
- '/api/**': './custom/**'
143
+ '/api/**': '../custom/**'
144
144
  }
145
145
  };
146
146
 
@@ -25,7 +25,7 @@ export default {
25
25
  // Configure double asterisk wildcard route
26
26
  const config = {
27
27
  customRoutes: {
28
- '/src/**': './src/**'
28
+ '/src/**': '../src/**'
29
29
  }
30
30
  };
31
31
 
@@ -86,7 +86,7 @@ export default {
86
86
  // Configure single asterisk wildcard route
87
87
  const config = {
88
88
  customRoutes: {
89
- '/src/*': './src/*'
89
+ '/src/*': '../src/*'
90
90
  }
91
91
  };
92
92
 
@@ -140,7 +140,7 @@ export default {
140
140
  // Configure wildcard route that overrides static file
141
141
  const config = {
142
142
  customRoutes: {
143
- '/api/**': './custom/**'
143
+ '/api/**': '../custom/**'
144
144
  }
145
145
  };
146
146