koa-classic-server 3.0.0-alpha.0 → 3.0.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.
@@ -7,66 +7,16 @@ const root = path.join(__dirname, 'hidden-fixtures');
7
7
 
8
8
  function createApp(hiddenOpts) {
9
9
  const app = new Koa();
10
- app.use(koaClassicServer(root, { showDirContents: true, hidden: hiddenOpts }));
10
+ app.use(koaClassicServer(root, { dirListing: { enabled: true }, hidden: hiddenOpts }));
11
11
  return app.listen();
12
12
  }
13
13
 
14
- // ─── Implicit default warning ─────────────────────────────────────────────────
15
- // This describe block MUST be first: the warning flag is module-level and fires
16
- // only once per Jest worker process. Jest isolates each test FILE into its own
17
- // module registry, so the flag starts as false when this file loads.
18
-
19
- describe('hidden option implicit default warning', () => {
20
- let warnSpy;
21
-
22
- beforeAll(() => {
23
- warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
24
- });
25
-
26
- afterAll(() => {
27
- warnSpy.mockRestore();
28
- });
29
-
30
- test('warns when dotFiles.default and dotDirs.default are not explicitly set', () => {
31
- warnSpy.mockClear();
32
- koaClassicServer(root, {}); // no hidden config at all — both defaults implicit
33
- const warnings = warnSpy.mock.calls.filter(c =>
34
- c[1] && c[1].includes('dotFiles') && c[1].includes('dotDirs')
35
- );
36
- expect(warnings.length).toBe(1);
37
- expect(warnings[0][1]).toContain('hidden.dotFiles.default');
38
- expect(warnings[0][1]).toContain('v3.0.0');
39
- });
40
-
41
- test('does not warn again on subsequent calls (once per process)', () => {
42
- warnSpy.mockClear();
43
- koaClassicServer(root, {}); // flag already set from the test above
44
- const warnings = warnSpy.mock.calls.filter(c =>
45
- c[1] && c[1].includes('dotFiles')
46
- );
47
- expect(warnings.length).toBe(0);
48
- });
49
-
50
- test('no warning when both dotFiles.default and dotDirs.default are explicitly set', () => {
51
- // Use jest.isolateModules to obtain a fresh copy of index.cjs whose flag is false,
52
- // then verify that an explicit configuration emits no warning.
53
- let fresh;
54
- jest.isolateModules(() => {
55
- fresh = require('../index.cjs');
56
- });
57
- warnSpy.mockClear();
58
- fresh(root, {
59
- hidden: {
60
- dotFiles: { default: 'hidden' },
61
- dotDirs: { default: 'visible' },
62
- }
63
- });
64
- const warnings = warnSpy.mock.calls.filter(c =>
65
- c[1] && c[1].includes('dotFiles')
66
- );
67
- expect(warnings.length).toBe(0);
68
- });
69
- });
14
+ // Note: prior to v3.0.0 the dotFiles default was 'hidden' (a v3-alpha
15
+ // security-by-default choice) and a once-per-process warning was emitted
16
+ // when the option was implicit. The default was reverted to 'visible' to
17
+ // align with the "HTTP file server first" design philosophy (see
18
+ // CLAUDE.md). The warning is no longer emitted because the default is no
19
+ // longer a surprising restriction; tests for it have been removed.
70
20
 
71
21
  // ─── Option validation ────────────────────────────────────────────────────────
72
22
 
@@ -100,14 +50,49 @@ describe('hidden option — validation', () => {
100
50
  });
101
51
  });
102
52
 
103
- // ─── dotFiles default: 'hidden' (system default) ─────────────────────────────
53
+ // ─── dotFiles default visible (system default in v3.0+) ────────────────────
104
54
 
105
- describe('dotFiles — default hidden (system default)', () => {
55
+ describe('dotFiles — default visible (system default)', () => {
106
56
  let server;
107
57
  beforeAll(() => { server = createApp(undefined); });
108
58
  afterAll(() => server.close());
109
59
 
110
- test('GET /.env returns 404', async () => {
60
+ // Per the V3 "HTTP file server first" philosophy (see CLAUDE.md), dot-files
61
+ // are visible by default. Operators harden by setting hidden.dotFiles.default
62
+ // to 'hidden' or by using blacklist / alwaysHide.
63
+ test('GET /.env returns 200 by default', async () => {
64
+ const res = await supertest(server).get('/.env');
65
+ expect(res.status).toBe(200);
66
+ });
67
+
68
+ test('GET /.gitignore returns 200 by default', async () => {
69
+ const res = await supertest(server).get('/.gitignore');
70
+ expect(res.status).toBe(200);
71
+ });
72
+
73
+ test('directory listing includes dot-files', async () => {
74
+ const res = await supertest(server).get('/');
75
+ expect(res.status).toBe(200);
76
+ expect(res.text).toContain('.env');
77
+ expect(res.text).toContain('.gitignore');
78
+ });
79
+
80
+ test('regular files remain accessible', async () => {
81
+ const res = await supertest(server).get('/normal.txt');
82
+ expect(res.status).toBe(200);
83
+ });
84
+ });
85
+
86
+ // ─── dotFiles default: 'hidden' (opt-in hardening) ───────────────────────────
87
+
88
+ describe('dotFiles — explicit default hidden (opt-in)', () => {
89
+ let server;
90
+ beforeAll(() => {
91
+ server = createApp({ dotFiles: { default: 'hidden' } });
92
+ });
93
+ afterAll(() => server.close());
94
+
95
+ test('GET /.env returns 404 when dotFiles.default = "hidden"', async () => {
111
96
  const res = await supertest(server).get('/.env');
112
97
  expect(res.status).toBe(404);
113
98
  });
@@ -139,7 +124,7 @@ describe('dotFiles — default hidden (system default)', () => {
139
124
  });
140
125
  });
141
126
 
142
- // ─── dotFiles default: 'visible' ─────────────────────────────────────────────
127
+ // ─── dotFiles default: 'visible' (kept for completeness with explicit opt-in) ─
143
128
 
144
129
  describe('dotFiles — default visible', () => {
145
130
  let server;
@@ -400,9 +385,9 @@ describe('alwaysHide — secondary to dotFiles whitelist', () => {
400
385
 
401
386
  // ─── deep tree: dot-files hidden at any depth ─────────────────────────────────
402
387
 
403
- describe('hidden entries at any depth in directory tree', () => {
388
+ describe('hidden entries at any depth in directory tree (opt-in dotFiles.default=hidden)', () => {
404
389
  let server;
405
- beforeAll(() => { server = createApp(undefined); });
390
+ beforeAll(() => { server = createApp({ dotFiles: { default: 'hidden' } }); });
406
391
  afterAll(() => server.close());
407
392
 
408
393
  test('GET /subdir/.env returns 404 (dot-file hidden at any depth)', async () => {
@@ -31,7 +31,7 @@ describe('hideExtension option tests', () => {
31
31
  beforeAll(() => {
32
32
  app = new Koa();
33
33
  app.use(koaClassicServer(rootDir, {
34
- showDirContents: true,
34
+ dirListing: { enabled: true },
35
35
  index: ['index.ejs'],
36
36
  hideExtension: { ext: '.ejs' },
37
37
  template: {
@@ -83,7 +83,7 @@ describe('hideExtension option tests', () => {
83
83
  beforeAll(() => {
84
84
  app = new Koa();
85
85
  app.use(koaClassicServer(rootDir, {
86
- showDirContents: true,
86
+ dirListing: { enabled: true },
87
87
  index: ['index.ejs'],
88
88
  hideExtension: { ext: '.ejs' },
89
89
  template: {
@@ -141,7 +141,7 @@ describe('hideExtension option tests', () => {
141
141
  beforeAll(() => {
142
142
  app = new Koa();
143
143
  app.use(koaClassicServer(rootDir, {
144
- showDirContents: true,
144
+ dirListing: { enabled: true },
145
145
  index: ['index.ejs'],
146
146
  hideExtension: { ext: '.ejs', redirect: 302 }
147
147
  }));
@@ -159,15 +159,15 @@ describe('hideExtension option tests', () => {
159
159
  });
160
160
 
161
161
  // ==========================================
162
- // Directory/file conflict (showDirContents: true)
162
+ // Directory/file conflict (dirListing: { enabled: true })
163
163
  // ==========================================
164
- describe('Directory/file conflict with showDirContents: true', () => {
164
+ describe('Directory/file conflict with dirListing: { enabled: true }', () => {
165
165
  let app, server, request;
166
166
 
167
167
  beforeAll(() => {
168
168
  app = new Koa();
169
169
  app.use(koaClassicServer(rootDir, {
170
- showDirContents: true,
170
+ dirListing: { enabled: true },
171
171
  index: ['index.html'],
172
172
  hideExtension: { ext: '.ejs' },
173
173
  template: {
@@ -208,7 +208,7 @@ describe('hideExtension option tests', () => {
208
208
  beforeAll(() => {
209
209
  app = new Koa();
210
210
  app.use(koaClassicServer(rootDir, {
211
- showDirContents: true,
211
+ dirListing: { enabled: true },
212
212
  hideExtension: { ext: '.ejs' }
213
213
  }));
214
214
  server = app.listen();
@@ -232,7 +232,7 @@ describe('hideExtension option tests', () => {
232
232
  beforeAll(() => {
233
233
  app = new Koa();
234
234
  app.use(koaClassicServer(rootDir, {
235
- showDirContents: true,
235
+ dirListing: { enabled: true },
236
236
  hideExtension: { ext: '.ejs' },
237
237
  template: {
238
238
  ext: ['ejs'],
@@ -275,7 +275,7 @@ describe('hideExtension option tests', () => {
275
275
  });
276
276
 
277
277
  const middleware = koaClassicServer(rootDir, {
278
- showDirContents: true,
278
+ dirListing: { enabled: true },
279
279
  index: ['index.ejs'],
280
280
  urlsReserved: ['/blog'],
281
281
  hideExtension: { ext: '.ejs' },
@@ -335,7 +335,7 @@ describe('hideExtension option tests', () => {
335
335
  });
336
336
 
337
337
  app.use(koaClassicServer(rootDir, {
338
- showDirContents: true,
338
+ dirListing: { enabled: true },
339
339
  useOriginalUrl: false,
340
340
  hideExtension: { ext: '.ejs' },
341
341
  template: {
@@ -377,7 +377,7 @@ describe('hideExtension option tests', () => {
377
377
  beforeAll(() => {
378
378
  app = new Koa();
379
379
  app.use(koaClassicServer(rootDir, {
380
- showDirContents: true,
380
+ dirListing: { enabled: true },
381
381
  hideExtension: { ext: '.ejs' }
382
382
  }));
383
383
  server = app.listen();
@@ -404,7 +404,7 @@ describe('hideExtension option tests', () => {
404
404
  beforeAll(() => {
405
405
  app = new Koa();
406
406
  app.use(koaClassicServer(rootDir, {
407
- showDirContents: true,
407
+ dirListing: { enabled: true },
408
408
  hideExtension: { ext: '.ejs' }
409
409
  }));
410
410
  server = app.listen();
@@ -442,7 +442,7 @@ describe('hideExtension option tests', () => {
442
442
  beforeAll(() => {
443
443
  app = new Koa();
444
444
  app.use(koaClassicServer(rootDir, {
445
- showDirContents: true,
445
+ dirListing: { enabled: true },
446
446
  index: ['index.ejs'],
447
447
  hideExtension: { ext: '.ejs' },
448
448
  template: {
@@ -547,4 +547,61 @@ describe('hideExtension option tests', () => {
547
547
  }).not.toThrow();
548
548
  });
549
549
  });
550
+
551
+ // ==========================================
552
+ // Security: open-redirect via protocol-relative URL
553
+ // ==========================================
554
+ describe('Open-redirect guard on hideExtension Location header', () => {
555
+ let app, server, port;
556
+
557
+ beforeAll((done) => {
558
+ app = new Koa();
559
+ app.use(koaClassicServer(rootDir, {
560
+ dirListing: { enabled: true },
561
+ hideExtension: { ext: '.ejs' }
562
+ }));
563
+ server = app.listen(0, () => {
564
+ port = server.address().port;
565
+ done();
566
+ });
567
+ });
568
+
569
+ afterAll(() => { server.close(); });
570
+
571
+ // Send the request path as raw bytes so the leading "//" survives any
572
+ // client-side URL normalization. supertest/url.parse would collapse it.
573
+ function rawGet(path) {
574
+ const http = require('http');
575
+ return new Promise((resolve, reject) => {
576
+ const req = http.request({
577
+ host: '127.0.0.1',
578
+ port,
579
+ method: 'GET',
580
+ path
581
+ }, (res) => {
582
+ res.resume();
583
+ resolve({ status: res.statusCode, location: res.headers.location });
584
+ });
585
+ req.on('error', reject);
586
+ req.end();
587
+ });
588
+ }
589
+
590
+ test('GET //evil.com/foo.ejs → Location must not be protocol-relative', async () => {
591
+ const res = await rawGet('//evil.com/foo.ejs');
592
+ expect(res.status).toBe(301);
593
+ expect(res.location).toBeDefined();
594
+ // The Location must not start with "//" or "/\" (would navigate off-origin)
595
+ expect(res.location.startsWith('//')).toBe(false);
596
+ expect(res.location.startsWith('/\\')).toBe(false);
597
+ expect(res.location).toBe('/evil.com/foo');
598
+ });
599
+
600
+ test('GET ///a/b.ejs → leading slashes collapsed in Location', async () => {
601
+ const res = await rawGet('///a/b.ejs');
602
+ expect(res.status).toBe(301);
603
+ expect(res.location.startsWith('//')).toBe(false);
604
+ expect(res.location).toBe('/a/b');
605
+ });
606
+ });
550
607
  });
@@ -33,7 +33,7 @@ const filesAndDirArray = getFilesRecursivelySync(rootDir);
33
33
  const options0 = {
34
34
  urlPrefix: '/public', // Il prefisso URL che il middleware dovrà intercettare
35
35
  method: ['GET'],// I metodi HTTP ammessi (default 'GET')
36
- showDirContents: true,// Se mostrare il contenuto della directory in caso di richiesta ad una cartella
36
+ dirListing: { enabled: true },// Se mostrare il contenuto della directory in caso di richiesta ad una cartella
37
37
  //index: 'index.html', // Nome del file index da cercare all'interno di una directory (se presente)
38
38
  };
39
39
 
@@ -79,7 +79,7 @@ describe(` koaClassicServer options0: ${JSON.stringify(options0)}`, () => {
79
79
  //START option1
80
80
  const options1 = {
81
81
  method: ['GET'],
82
- showDirContents: true,
82
+ dirListing: { enabled: true },
83
83
  };
84
84
 
85
85
  describe(` koaClassicServer options1: ${JSON.stringify(options1)}`, () => {
@@ -114,7 +114,7 @@ describe(` koaClassicServer options1: ${JSON.stringify(options1)}`, () => {
114
114
  //STASRT option2
115
115
  const options2 = {
116
116
  method: ['GET'],
117
- showDirContents: false,
117
+ dirListing: { enabled: false },
118
118
  index: ['index.html'],
119
119
  };
120
120
 
@@ -141,7 +141,7 @@ describe(` koaClassicServer options2: ${JSON.stringify(options2)}`, () => {
141
141
  //STASRT option3
142
142
  const options3 = {
143
143
  method: ['GET'],
144
- showDirContents: false,
144
+ dirListing: { enabled: false },
145
145
  urlsReserved : Array('/percorso_riservato', '/percorso riservato con spazi')
146
146
  };
147
147
 
@@ -188,7 +188,7 @@ describe(` koaClassicServer options2: ${JSON.stringify(options2)}`, () => {
188
188
  // I metodi HTTP ammessi (default 'GET')
189
189
  method: ['GET'],
190
190
  // Se mostrare il contenuto della directory in caso di richiesta ad una cartella
191
- showDirContents: true,
191
+ dirListing: { enabled: true },
192
192
  // Nome del file index da cercare all'interno di una directory (se presente)
193
193
  //index: 'index.html',
194
194
  }; */
@@ -268,7 +268,7 @@ function testAllPathByFileList(filesAndDirArray, getServer, options) {
268
268
  const responseBody = res.text !== undefined ? res.text : res.body.toString('utf8');
269
269
  expect(responseBody).toBe(content);
270
270
  } else {//è una directory
271
- if( options.showDirContents === false ){
271
+ if( options.dirListing && options.dirListing.enabled === false ){
272
272
  // FIX: Quando directory listing è disabilitato, restituisce 404
273
273
  expect(res.status).toBe(404);
274
274
  expect(res.type).toBe('text/html');