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.
- package/CLAUDE.md +101 -0
- package/README.md +550 -635
- package/__tests__/benchmark-results-v3.0.0.txt +372 -0
- package/__tests__/benchmark.js +1 -1
- package/__tests__/compression.test.js +17 -3
- package/__tests__/customTest/serversToLoad.util.js +4 -4
- package/__tests__/demo-regex-index.js +4 -4
- package/__tests__/directory-sorting-links.test.js +1 -1
- package/__tests__/dt-unknown.test.js +19 -19
- package/__tests__/ejs.test.js +1 -1
- package/__tests__/hidden-option.test.js +48 -63
- package/__tests__/hideExtension.test.js +70 -13
- package/__tests__/index.test.js +6 -6
- package/__tests__/listing.test.js +437 -0
- package/__tests__/logger.test.js +232 -0
- package/__tests__/range.test.js +2 -2
- package/__tests__/security-headers.test.js +20 -8
- package/__tests__/security.test.js +5 -5
- package/__tests__/server-cache.test.js +178 -7
- package/__tests__/symlink.test.js +10 -10
- package/__tests__/template-timeout.test.js +321 -0
- package/docs/CHANGELOG.md +209 -4
- package/docs/CODE_REVIEW.md +2 -0
- package/docs/DOCUMENTATION.md +259 -32
- package/docs/EXAMPLES_INDEX_OPTION.md +1 -1
- package/docs/FLOW_DIAGRAM.md +2 -0
- package/docs/INDEX_OPTION_PRIORITY.md +2 -2
- package/docs/OPTIMIZATION_HTTP_CACHING.md +2 -0
- package/docs/security_improvement_for_V3.md +421 -0
- package/docs/template-engine/TEMPLATE_ENGINE_GUIDE.md +5 -5
- package/docs/template-engine/esempi-incrementali.js +1 -1
- package/index.cjs +551 -178
- package/package.json +6 -1
|
@@ -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, {
|
|
10
|
+
app.use(koaClassicServer(root, { dirListing: { enabled: true }, hidden: hiddenOpts }));
|
|
11
11
|
return app.listen();
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
53
|
+
// ─── dotFiles — default visible (system default in v3.0+) ────────────────────
|
|
104
54
|
|
|
105
|
-
describe('dotFiles — 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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
162
|
+
// Directory/file conflict (dirListing: { enabled: true })
|
|
163
163
|
// ==========================================
|
|
164
|
-
describe('Directory/file conflict with
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/__tests__/index.test.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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');
|