koa-classic-server 3.0.0-alpha.0 → 3.0.1
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__/head-method.test.js +160 -0
- 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 +235 -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 +587 -178
- package/package.json +6 -1
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
const Koa = require('koa');
|
|
2
|
+
const koaClassicServer = require('../index.cjs');
|
|
3
|
+
const supertest = require('supertest');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const ejs = require('ejs');
|
|
7
|
+
|
|
8
|
+
// Regression tests for the HEAD-method HTTP-conformance bug.
|
|
9
|
+
//
|
|
10
|
+
// RFC 9110 §9.3.2: a HEAD response must be identical to the GET response for the
|
|
11
|
+
// same resource, minus the message body — same status code, same headers
|
|
12
|
+
// (notably Content-Type and Content-Length).
|
|
13
|
+
//
|
|
14
|
+
// Before the fix, a route served by the template engine returned 200 on GET but
|
|
15
|
+
// 404 on HEAD whenever the operator's render function did not itself handle HEAD
|
|
16
|
+
// (e.g. it returned early on non-GET requests). The static-file branch already
|
|
17
|
+
// handled HEAD correctly, and directory listings happened to work because Koa
|
|
18
|
+
// strips the string body for HEAD — only the template branch was broken.
|
|
19
|
+
|
|
20
|
+
const ROOT = path.join(__dirname, 'publicWwwTest');
|
|
21
|
+
|
|
22
|
+
// Minimal data for the templates exercised here. ejs-templates/index.ejs needs no
|
|
23
|
+
// variables; simple.ejs needs these four. Anything else renders with no locals.
|
|
24
|
+
function templateData(filePath) {
|
|
25
|
+
if (path.basename(filePath, '.ejs') === 'simple') {
|
|
26
|
+
return {
|
|
27
|
+
title: 'Simple EJS Test',
|
|
28
|
+
heading: 'Hello from EJS',
|
|
29
|
+
message: 'This is a simple template test',
|
|
30
|
+
timestamp: '2025-11-18',
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A method-AWARE render: it only produces a body on GET. This is a common
|
|
37
|
+
// real-world pattern (operators guard render work behind a GET check) and is
|
|
38
|
+
// exactly what exposed the bug — on HEAD it never set ctx.body, so the status
|
|
39
|
+
// stayed at Koa's default 404. This render is what makes the regression real:
|
|
40
|
+
// with a naive render the test would pass even without the fix, because Koa
|
|
41
|
+
// strips the body of a 200 GET response for HEAD on its own.
|
|
42
|
+
const methodAwareRender = async (ctx, next, filePath) => {
|
|
43
|
+
if (ctx.method !== 'GET') return;
|
|
44
|
+
const tpl = await fs.promises.readFile(filePath, 'utf-8');
|
|
45
|
+
ctx.type = 'text/html';
|
|
46
|
+
ctx.body = ejs.render(tpl, templateData(filePath));
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// A method-AGNOSTIC render: sets the body regardless of method. Proves the fix
|
|
50
|
+
// does not regress renders that already worked.
|
|
51
|
+
const naiveRender = async (ctx, next, filePath) => {
|
|
52
|
+
const tpl = await fs.promises.readFile(filePath, 'utf-8');
|
|
53
|
+
ctx.type = 'text/html';
|
|
54
|
+
ctx.body = ejs.render(tpl, templateData(filePath));
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function buildServer(render) {
|
|
58
|
+
const app = new Koa();
|
|
59
|
+
app.use(
|
|
60
|
+
koaClassicServer(ROOT, {
|
|
61
|
+
method: ['GET', 'HEAD'],
|
|
62
|
+
index: ['index.html', 'index.ejs'],
|
|
63
|
+
dirListing: { enabled: true },
|
|
64
|
+
template: { ext: ['ejs'], render },
|
|
65
|
+
})
|
|
66
|
+
);
|
|
67
|
+
return app.listen();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Asserts the HEAD response for `urlPath` matches the corresponding GET in status,
|
|
71
|
+
// Content-Type and Content-Length, and that HEAD carries no body.
|
|
72
|
+
async function expectHeadMatchesGet(request, urlPath, expectedStatus) {
|
|
73
|
+
const get = await request.get(urlPath);
|
|
74
|
+
const head = await request.head(urlPath);
|
|
75
|
+
|
|
76
|
+
expect(get.status).toBe(expectedStatus);
|
|
77
|
+
expect(head.status).toBe(expectedStatus);
|
|
78
|
+
|
|
79
|
+
// Same headers as GET (RFC 9110 §9.3.2)
|
|
80
|
+
expect(head.headers['content-type']).toBe(get.headers['content-type']);
|
|
81
|
+
expect(head.headers['content-length']).toBe(get.headers['content-length']);
|
|
82
|
+
// Content-Length must actually be populated for a body-bearing response
|
|
83
|
+
expect(head.headers['content-length']).toBeDefined();
|
|
84
|
+
|
|
85
|
+
// HEAD must not send a body (supertest surfaces an empty object for HEAD)
|
|
86
|
+
expect(head.text).toBeFalsy();
|
|
87
|
+
expect(head.body).toEqual({});
|
|
88
|
+
|
|
89
|
+
return { get, head };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe('HEAD method — template engine routes (method-aware render)', () => {
|
|
93
|
+
let server;
|
|
94
|
+
let request;
|
|
95
|
+
beforeAll(() => { server = buildServer(methodAwareRender); request = supertest(server); });
|
|
96
|
+
afterAll(() => server.close());
|
|
97
|
+
|
|
98
|
+
// The headline bug: a directory whose index is a template (index.ejs as the
|
|
99
|
+
// index of /ejs-templates/). GET 200, HEAD must also be 200 — not 404.
|
|
100
|
+
test('HEAD on a directory whose index is a template → 200, matches GET', async () => {
|
|
101
|
+
const { get } = await expectHeadMatchesGet(request, '/ejs-templates/', 200);
|
|
102
|
+
// sanity: GET really did render the template
|
|
103
|
+
expect(get.text).toContain('Test Templates EJS');
|
|
104
|
+
expect(get.headers['content-type']).toMatch(/text\/html/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('HEAD on a directly-requested template file → 200, matches GET', async () => {
|
|
108
|
+
const { get } = await expectHeadMatchesGet(request, '/ejs-templates/simple.ejs', 200);
|
|
109
|
+
expect(get.text).toContain('Hello from EJS');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('HEAD on a static file → 200, matches GET', async () => {
|
|
113
|
+
const { head } = await expectHeadMatchesGet(request, '/test.txt', 200);
|
|
114
|
+
// static branch advertises range support
|
|
115
|
+
expect(head.headers['accept-ranges']).toBe('bytes');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('HEAD on a listable directory (no index) → 200, matches GET', async () => {
|
|
119
|
+
const { get } = await expectHeadMatchesGet(request, '/cartella/', 200);
|
|
120
|
+
expect(get.headers['content-type']).toMatch(/text\/html/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('HEAD on a non-existent template path → 404, matches GET', async () => {
|
|
124
|
+
const get = await request.get('/ejs-templates/non-existent.ejs');
|
|
125
|
+
const head = await request.head('/ejs-templates/non-existent.ejs');
|
|
126
|
+
expect(get.status).toBe(404);
|
|
127
|
+
expect(head.status).toBe(404);
|
|
128
|
+
expect(head.headers['content-type']).toBe(get.headers['content-type']);
|
|
129
|
+
expect(head.text).toBeFalsy();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('HEAD on a non-existent static path → 404, matches GET', async () => {
|
|
133
|
+
const get = await request.get('/does-not-exist.txt');
|
|
134
|
+
const head = await request.head('/does-not-exist.txt');
|
|
135
|
+
expect(get.status).toBe(404);
|
|
136
|
+
expect(head.status).toBe(404);
|
|
137
|
+
expect(head.text).toBeFalsy();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('HEAD method — template engine routes (method-agnostic render, no regression)', () => {
|
|
142
|
+
let server;
|
|
143
|
+
let request;
|
|
144
|
+
beforeAll(() => { server = buildServer(naiveRender); request = supertest(server); });
|
|
145
|
+
afterAll(() => server.close());
|
|
146
|
+
|
|
147
|
+
test('HEAD on template index still matches GET → 200', async () => {
|
|
148
|
+
await expectHeadMatchesGet(request, '/ejs-templates/', 200);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('HEAD on direct template still matches GET → 200', async () => {
|
|
152
|
+
await expectHeadMatchesGet(request, '/ejs-templates/simple.ejs', 200);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('HEAD on non-existent template → 404', async () => {
|
|
156
|
+
const head = await request.head('/ejs-templates/non-existent.ejs');
|
|
157
|
+
expect(head.status).toBe(404);
|
|
158
|
+
expect(head.text).toBeFalsy();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -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');
|