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
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
const Koa = require('koa');
|
|
2
|
+
const koaClassicServer = require('../index.cjs');
|
|
3
|
+
const supertest = require('supertest');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const ROOT = path.join(__dirname, 'publicWwwTest');
|
|
7
|
+
const TEMPLATE_URL = '/ejs-templates/simple.ejs';
|
|
8
|
+
|
|
9
|
+
function buildServer(renderFn, opts = {}) {
|
|
10
|
+
const app = new Koa();
|
|
11
|
+
app.silent = true;
|
|
12
|
+
app.use(koaClassicServer(ROOT, {
|
|
13
|
+
method: ['GET'],
|
|
14
|
+
hidden: { dotFiles: { default: 'hidden' }, dotDirs: { default: 'visible' } },
|
|
15
|
+
template: {
|
|
16
|
+
ext: ['ejs'],
|
|
17
|
+
render: renderFn,
|
|
18
|
+
...(opts.renderTimeout !== undefined ? { renderTimeout: opts.renderTimeout } : {})
|
|
19
|
+
}
|
|
20
|
+
}));
|
|
21
|
+
const server = app.listen();
|
|
22
|
+
return { server, request: supertest(server) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Sleep that does not keep the event loop alive — used to simulate
|
|
26
|
+
// non-cooperative long-running work without leaving orphan timers behind.
|
|
27
|
+
function unrefSleep(ms) {
|
|
28
|
+
return new Promise(resolve => {
|
|
29
|
+
const t = setTimeout(resolve, ms);
|
|
30
|
+
if (typeof t.unref === 'function') t.unref();
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('template.renderTimeout', () => {
|
|
35
|
+
let consoleErrorSpy;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
consoleErrorSpy.mockRestore();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('Factory validation', () => {
|
|
46
|
+
test('rejects negative renderTimeout', () => {
|
|
47
|
+
expect(() => koaClassicServer(ROOT, {
|
|
48
|
+
template: { ext: ['ejs'], render: () => {}, renderTimeout: -1 }
|
|
49
|
+
})).toThrow(/renderTimeout must be a finite number/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('rejects non-number renderTimeout', () => {
|
|
53
|
+
expect(() => koaClassicServer(ROOT, {
|
|
54
|
+
template: { ext: ['ejs'], render: () => {}, renderTimeout: '5000' }
|
|
55
|
+
})).toThrow(/renderTimeout must be a finite number/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('rejects NaN renderTimeout', () => {
|
|
59
|
+
expect(() => koaClassicServer(ROOT, {
|
|
60
|
+
template: { ext: ['ejs'], render: () => {}, renderTimeout: NaN }
|
|
61
|
+
})).toThrow(/renderTimeout must be a finite number/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('rejects Infinity renderTimeout', () => {
|
|
65
|
+
expect(() => koaClassicServer(ROOT, {
|
|
66
|
+
template: { ext: ['ejs'], render: () => {}, renderTimeout: Infinity }
|
|
67
|
+
})).toThrow(/renderTimeout must be a finite number/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('accepts 0 (disabled)', () => {
|
|
71
|
+
expect(() => koaClassicServer(ROOT, {
|
|
72
|
+
template: { ext: ['ejs'], render: () => {}, renderTimeout: 0 }
|
|
73
|
+
})).not.toThrow();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('accepts positive integer', () => {
|
|
77
|
+
expect(() => koaClassicServer(ROOT, {
|
|
78
|
+
template: { ext: ['ejs'], render: () => {}, renderTimeout: 1000 }
|
|
79
|
+
})).not.toThrow();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('defaults to 30000 when undefined', () => {
|
|
83
|
+
const opts = { template: { ext: ['ejs'], render: () => {} } };
|
|
84
|
+
koaClassicServer(ROOT, opts);
|
|
85
|
+
expect(opts.template.renderTimeout).toBe(30000);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('Successful render within timeout', () => {
|
|
90
|
+
let env;
|
|
91
|
+
afterEach(() => env && env.server.close());
|
|
92
|
+
|
|
93
|
+
test('returns 200 when render completes before timeout', async () => {
|
|
94
|
+
env = buildServer(async (ctx) => {
|
|
95
|
+
await new Promise(r => setTimeout(r, 20));
|
|
96
|
+
ctx.type = 'text/html';
|
|
97
|
+
ctx.body = '<p>ok</p>';
|
|
98
|
+
}, { renderTimeout: 500 });
|
|
99
|
+
|
|
100
|
+
const res = await env.request.get(TEMPLATE_URL);
|
|
101
|
+
expect(res.status).toBe(200);
|
|
102
|
+
expect(res.text).toContain('ok');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('Timeout behaviour', () => {
|
|
107
|
+
let env;
|
|
108
|
+
afterEach(() => env && env.server.close());
|
|
109
|
+
|
|
110
|
+
test('returns 504 when render exceeds timeout', async () => {
|
|
111
|
+
env = buildServer(async () => {
|
|
112
|
+
await unrefSleep(5000);
|
|
113
|
+
}, { renderTimeout: 100 });
|
|
114
|
+
|
|
115
|
+
const res = await env.request.get(TEMPLATE_URL);
|
|
116
|
+
expect(res.status).toBe(504);
|
|
117
|
+
expect(res.text).toContain('Gateway Timeout');
|
|
118
|
+
expect(res.text).toContain('took too long to render');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('504 response carries security headers', async () => {
|
|
122
|
+
env = buildServer(async () => {
|
|
123
|
+
await unrefSleep(5000);
|
|
124
|
+
}, { renderTimeout: 50 });
|
|
125
|
+
|
|
126
|
+
const res = await env.request.get(TEMPLATE_URL);
|
|
127
|
+
expect(res.status).toBe(504);
|
|
128
|
+
expect(res.headers['content-security-policy']).toMatch(/default-src 'none'/);
|
|
129
|
+
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
|
130
|
+
expect(res.headers['x-frame-options']).toBe('DENY');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('renderTimeout: 0 disables the timer (render is allowed to run long)', async () => {
|
|
134
|
+
env = buildServer(async (ctx) => {
|
|
135
|
+
await new Promise(r => setTimeout(r, 200));
|
|
136
|
+
ctx.type = 'text/html';
|
|
137
|
+
ctx.body = '<p>slow but ok</p>';
|
|
138
|
+
}, { renderTimeout: 0 });
|
|
139
|
+
|
|
140
|
+
const res = await env.request.get(TEMPLATE_URL);
|
|
141
|
+
expect(res.status).toBe(200);
|
|
142
|
+
expect(res.text).toContain('slow but ok');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('Render argument contract', () => {
|
|
147
|
+
let env;
|
|
148
|
+
afterEach(() => env && env.server.close());
|
|
149
|
+
|
|
150
|
+
test('render is called with (ctx, next, filePath, rawBuffer, signal) in that order', async () => {
|
|
151
|
+
let received;
|
|
152
|
+
env = buildServer(async (...args) => {
|
|
153
|
+
received = args;
|
|
154
|
+
args[0].body = 'ok';
|
|
155
|
+
}, { renderTimeout: 1000 });
|
|
156
|
+
|
|
157
|
+
await env.request.get(TEMPLATE_URL);
|
|
158
|
+
|
|
159
|
+
expect(received).toHaveLength(5);
|
|
160
|
+
// ctx: Koa context — must expose req/res/state
|
|
161
|
+
expect(received[0]).toBeDefined();
|
|
162
|
+
expect(received[0].req).toBeDefined();
|
|
163
|
+
expect(received[0].res).toBeDefined();
|
|
164
|
+
expect(received[0].state).toBeDefined();
|
|
165
|
+
// next: function (downstream middleware)
|
|
166
|
+
expect(typeof received[1]).toBe('function');
|
|
167
|
+
// filePath: absolute path to the requested file
|
|
168
|
+
expect(typeof received[2]).toBe('string');
|
|
169
|
+
expect(path.isAbsolute(received[2])).toBe(true);
|
|
170
|
+
expect(received[2]).toMatch(/simple\.ejs$/);
|
|
171
|
+
// rawBuffer: Buffer or null depending on serverCache.rawFile state
|
|
172
|
+
expect(received[3] === null || Buffer.isBuffer(received[3])).toBe(true);
|
|
173
|
+
// signal: AbortSignal
|
|
174
|
+
expect(received[4]).toBeInstanceOf(AbortSignal);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('AbortSignal contract', () => {
|
|
179
|
+
let env;
|
|
180
|
+
afterEach(() => env && env.server.close());
|
|
181
|
+
|
|
182
|
+
test('passes an AbortSignal as 5th argument', async () => {
|
|
183
|
+
let receivedSignal;
|
|
184
|
+
env = buildServer(async (ctx, _next, _path, _buf, signal) => {
|
|
185
|
+
receivedSignal = signal;
|
|
186
|
+
ctx.body = 'ok';
|
|
187
|
+
}, { renderTimeout: 1000 });
|
|
188
|
+
|
|
189
|
+
await env.request.get(TEMPLATE_URL);
|
|
190
|
+
expect(receivedSignal).toBeInstanceOf(AbortSignal);
|
|
191
|
+
expect(receivedSignal.aborted).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('signal is NOT aborted after a successful render completes', async () => {
|
|
195
|
+
let signalAtEnd;
|
|
196
|
+
let signalRef;
|
|
197
|
+
env = buildServer(async (ctx, _next, _path, _buf, signal) => {
|
|
198
|
+
signalRef = signal;
|
|
199
|
+
await new Promise(r => setTimeout(r, 20));
|
|
200
|
+
signalAtEnd = signal.aborted;
|
|
201
|
+
ctx.type = 'text/html';
|
|
202
|
+
ctx.body = '<p>done</p>';
|
|
203
|
+
}, { renderTimeout: 1000 });
|
|
204
|
+
|
|
205
|
+
const res = await env.request.get(TEMPLATE_URL);
|
|
206
|
+
expect(res.status).toBe(200);
|
|
207
|
+
// Signal must not be aborted at the end of a successful render
|
|
208
|
+
expect(signalAtEnd).toBe(false);
|
|
209
|
+
// ...and must remain non-aborted after the handler finishes
|
|
210
|
+
// (i.e. our cleanup must not abort it as a side-effect)
|
|
211
|
+
await new Promise(r => setTimeout(r, 50));
|
|
212
|
+
expect(signalRef.aborted).toBe(false);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('signal aborts when render times out', async () => {
|
|
216
|
+
let signalAtTimeout = null;
|
|
217
|
+
env = buildServer(async (ctx, _next, _path, _buf, signal) => {
|
|
218
|
+
await unrefSleep(300);
|
|
219
|
+
signalAtTimeout = signal.aborted;
|
|
220
|
+
}, { renderTimeout: 50 });
|
|
221
|
+
|
|
222
|
+
const res = await env.request.get(TEMPLATE_URL);
|
|
223
|
+
expect(res.status).toBe(504);
|
|
224
|
+
await new Promise(r => setTimeout(r, 400));
|
|
225
|
+
expect(signalAtTimeout).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('cooperative render that honours signal terminates early', async () => {
|
|
229
|
+
const renderDuration = jest.fn();
|
|
230
|
+
env = buildServer(async (ctx, _next, _path, _buf, signal) => {
|
|
231
|
+
const start = Date.now();
|
|
232
|
+
try {
|
|
233
|
+
await new Promise((resolve, reject) => {
|
|
234
|
+
const t = setTimeout(resolve, 5000);
|
|
235
|
+
signal.addEventListener('abort', () => {
|
|
236
|
+
clearTimeout(t);
|
|
237
|
+
reject(new Error('aborted'));
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
} finally {
|
|
241
|
+
renderDuration(Date.now() - start);
|
|
242
|
+
}
|
|
243
|
+
}, { renderTimeout: 50 });
|
|
244
|
+
|
|
245
|
+
const res = await env.request.get(TEMPLATE_URL);
|
|
246
|
+
expect(res.status).toBe(504);
|
|
247
|
+
await new Promise(r => setTimeout(r, 200));
|
|
248
|
+
const elapsed = renderDuration.mock.calls[0]?.[0] ?? 9999;
|
|
249
|
+
expect(elapsed).toBeLessThan(1000);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe('Error handling integrity', () => {
|
|
254
|
+
let env;
|
|
255
|
+
afterEach(() => env && env.server.close());
|
|
256
|
+
|
|
257
|
+
test('late rejection after timeout does not crash the process', async () => {
|
|
258
|
+
const unhandledHandler = jest.fn();
|
|
259
|
+
process.on('unhandledRejection', unhandledHandler);
|
|
260
|
+
|
|
261
|
+
env = buildServer(async () => {
|
|
262
|
+
await new Promise(r => setTimeout(r, 100));
|
|
263
|
+
throw new Error('late failure');
|
|
264
|
+
}, { renderTimeout: 30 });
|
|
265
|
+
|
|
266
|
+
const res = await env.request.get(TEMPLATE_URL);
|
|
267
|
+
expect(res.status).toBe(504);
|
|
268
|
+
|
|
269
|
+
await new Promise(r => setTimeout(r, 250));
|
|
270
|
+
process.off('unhandledRejection', unhandledHandler);
|
|
271
|
+
expect(unhandledHandler).not.toHaveBeenCalled();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('synchronous render returning rejected promise still produces 500', async () => {
|
|
275
|
+
env = buildServer(() => Promise.reject(new Error('boom')), { renderTimeout: 1000 });
|
|
276
|
+
|
|
277
|
+
const res = await env.request.get(TEMPLATE_URL);
|
|
278
|
+
expect(res.status).toBe(500);
|
|
279
|
+
expect(res.text).toContain('Internal Server Error');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('synchronous throw in render produces 500', async () => {
|
|
283
|
+
env = buildServer(() => { throw new Error('sync boom'); }, { renderTimeout: 1000 });
|
|
284
|
+
|
|
285
|
+
const res = await env.request.get(TEMPLATE_URL);
|
|
286
|
+
expect(res.status).toBe(500);
|
|
287
|
+
expect(res.text).toContain('Internal Server Error');
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe('Client disconnect', () => {
|
|
292
|
+
let env;
|
|
293
|
+
afterEach(() => env && env.server.close());
|
|
294
|
+
|
|
295
|
+
test('signal aborts when client closes the connection before render finishes', async () => {
|
|
296
|
+
let abortObserved = false;
|
|
297
|
+
env = buildServer(async (ctx, _next, _path, _buf, signal) => {
|
|
298
|
+
signal.addEventListener('abort', () => { abortObserved = true; });
|
|
299
|
+
await unrefSleep(1000);
|
|
300
|
+
ctx.body = 'late';
|
|
301
|
+
}, { renderTimeout: 0 });
|
|
302
|
+
|
|
303
|
+
const addr = env.server.address();
|
|
304
|
+
const http = require('http');
|
|
305
|
+
await new Promise((resolve) => {
|
|
306
|
+
const req = http.request({
|
|
307
|
+
host: addr.address === '::' ? '127.0.0.1' : addr.address,
|
|
308
|
+
port: addr.port,
|
|
309
|
+
path: TEMPLATE_URL,
|
|
310
|
+
method: 'GET'
|
|
311
|
+
}, () => {});
|
|
312
|
+
req.on('error', () => {});
|
|
313
|
+
req.end();
|
|
314
|
+
setTimeout(() => { req.destroy(); resolve(); }, 50);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
await new Promise(r => setTimeout(r, 100));
|
|
318
|
+
expect(abortObserved).toBe(true);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
});
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,7 @@ All notable changes to koa-classic-server will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [3.0.0] -
|
|
8
|
+
## [3.0.0] - 2026-05-13
|
|
9
9
|
|
|
10
10
|
### 🆕 New Features
|
|
11
11
|
|
|
@@ -49,12 +49,197 @@ app.use(koaClassicServer('/public', {
|
|
|
49
49
|
**Blocked dot-dirs block sub-paths too:**
|
|
50
50
|
`GET /.git/config` returns 404 if `.git` is in `dotDirs.blacklist`.
|
|
51
51
|
|
|
52
|
+
#### `template.renderTimeout` — bounded template execution (Security M-1)
|
|
53
|
+
|
|
54
|
+
The template `render` callback now runs under a configurable timeout (default **30 000 ms**, `0` = disabled). When a render exceeds the timeout the middleware responds **`504 Gateway Timeout`** with the usual security headers, instead of leaving the client connection blocked on a slow/hung render. Protects against DoS via connection exhaustion when a render performs unbounded I/O (DB queries, remote fetches, etc.).
|
|
55
|
+
|
|
56
|
+
The `render` function now receives an **`AbortSignal` as 5th argument**. The signal aborts on timeout *and* when the client disconnects (even when `renderTimeout: 0`). Cooperative renders that propagate the signal to `fetch` / DB clients / `fs.promises.*` also free backend resources on timeout.
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
app.use(koaClassicServer('/public', {
|
|
60
|
+
template: {
|
|
61
|
+
ext: ['ejs'],
|
|
62
|
+
renderTimeout: 5000, // ms; 0 disables
|
|
63
|
+
render: async (ctx, next, filePath, rawBuffer, signal) => {
|
|
64
|
+
const data = await db.query('SELECT ...', { signal }); // honour signal
|
|
65
|
+
const ext = await fetch('https://api/...', { signal });
|
|
66
|
+
signal.throwIfAborted();
|
|
67
|
+
ctx.type = 'text/html';
|
|
68
|
+
ctx.body = ejs.render(rawBuffer.toString(), { data, ext });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}));
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Backward compatible:** existing 4-argument render functions keep working — the 5th argument is simply ignored.
|
|
75
|
+
|
|
76
|
+
#### `serverCache.*.maxAge` — time-based cache invalidation (Security M-2)
|
|
77
|
+
|
|
78
|
+
Both server-side caches (`serverCache.rawFile` and `serverCache.compressedFile`) accept a new `maxAge` option (ms, default `0` = disabled). When `> 0`, an entry is considered stale after `maxAge` ms regardless of `mtime + size`, forcing a fresh disk read on the next request.
|
|
79
|
+
|
|
80
|
+
Designed for **NFS / SMB / Docker bind mounts** where the OS attribute cache can keep `stat()` returning a stale `mtime` for several seconds after a remote modification — making the mtime+size invariant insufficient to detect changes. `maxAge` bounds the worst-case staleness window to a known value.
|
|
81
|
+
|
|
82
|
+
```js
|
|
83
|
+
app.use(koaClassicServer('/public', {
|
|
84
|
+
serverCache: {
|
|
85
|
+
rawFile: { enabled: true, maxAge: 30000 }, // refresh every 30 s
|
|
86
|
+
compressedFile: { enabled: true, maxAge: 30000 }
|
|
87
|
+
}
|
|
88
|
+
}));
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
> **Limitation:** `maxAge` limits but does not eliminate NFS staleness. For strict freshness combine with a low `actimeo=` on the mount.
|
|
92
|
+
|
|
93
|
+
Internally a new `LFUCache.refresh(key, fields)` method updates the entry in place while preserving its LFU frequency, so popular files refreshed by `maxAge` don't fall to the bottom of the eviction bucket.
|
|
94
|
+
|
|
95
|
+
#### `logger` option — pluggable structured logging (Security N-1)
|
|
96
|
+
|
|
97
|
+
All internal `console.error` / `console.warn` calls now route through an injectable logger. Pass any object that exposes `error(...)` and `warn(...)` methods — `console` (default), `pino`, `winston`, `bunyan`, or a custom adapter — to integrate with aggregated logging pipelines in production.
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
const pino = require('pino')();
|
|
101
|
+
|
|
102
|
+
app.use(koaClassicServer('/public', {
|
|
103
|
+
logger: pino
|
|
104
|
+
}));
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**Contract:**
|
|
108
|
+
- Must be an object with `typeof logger.error === 'function'` and `typeof logger.warn === 'function'`
|
|
109
|
+
- Invalid loggers (missing methods, non-objects, `null`, `false`, arrays) throw at factory time
|
|
110
|
+
- Extra methods (`info`, `debug`, `fatal`, ...) are ignored — pass any superset freely
|
|
111
|
+
|
|
112
|
+
**ANSI escape codes** in warning messages are only emitted when the logger is the global `console` (detected by reference). Structured loggers receive the plain text, keeping log aggregators clean.
|
|
113
|
+
|
|
114
|
+
**Backward compatible:** when `logger` is omitted, the default is `console` — existing code and tests that spy on `console.error` / `console.warn` continue to work unchanged.
|
|
115
|
+
|
|
116
|
+
#### `dirListing` namespace — bounded and paginated directory listings (Security N-2)
|
|
117
|
+
|
|
118
|
+
A new namespaced option groups all directory-listing config together, replacing the v2-era flat `showDirContents` knob with a structured object. Hardens the listing against indirect DoS via very large directories and improves usability on big folders.
|
|
119
|
+
|
|
120
|
+
```js
|
|
121
|
+
app.use(koaClassicServer('/public', {
|
|
122
|
+
dirListing: {
|
|
123
|
+
enabled: true, // render listing HTML when no index file matches (default: true)
|
|
124
|
+
maxEntries: 10000, // hard cap on visible / sorted / stat'd entries (default; 0 = disabled)
|
|
125
|
+
entriesPerPage: 100, // entries per page in the listing UI (default; 0 = disabled)
|
|
126
|
+
}
|
|
127
|
+
}));
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**dirListing.enabled**
|
|
131
|
+
- Replaces the v2 top-level `showDirContents`. Accepts `true` / `false`. When `false`, requests for a directory without a matching index file return 404 instead of an HTML listing.
|
|
132
|
+
|
|
133
|
+
**dirListing.maxEntries**
|
|
134
|
+
- Caps how many entries are sorted, stat'd, and rendered per directory listing. Excess entries trigger a yellow banner at the top of the page and an `X-Dir-Truncated: <N>` response header so monitoring can distinguish capped listings.
|
|
135
|
+
- Implementation: the middleware calls `fs.promises.readdir()` once and then slices the result. This bounds rendering and CPU cost but **not** the size of the initial `readdir()` allocation. For typical static-file servers (where the directory contents are controlled by the operator) this is the right trade-off — it recovers v2-class listing performance.
|
|
136
|
+
- Default `10000` is permissive enough for normal use while bounding rendering cost on accidentally-large folders.
|
|
137
|
+
- **Caveat for adversarial workloads:** if you serve a directory writable by untrusted parties, an attacker creating millions of files could still force a large `readdir()` allocation. Tracked for v3.1 as opt-in streaming reads — see `docs/security_improvement_for_V3.md` → *Future Work* → *[F-1]*.
|
|
138
|
+
|
|
139
|
+
**dirListing.entriesPerPage**
|
|
140
|
+
- Pagination kicks in only when the visible entries exceed `entriesPerPage`; small directories render in a single page exactly like before.
|
|
141
|
+
- The current page is selected by `?page=N` (0-based). Invalid or out-of-range values clamp silently to the nearest valid page.
|
|
142
|
+
- A numbered paginator (`« First | ‹ Prev | 0 1 … N-1 | Next › | Last »`) is rendered below the table, preserving any active `sort`/`order`. An `X-Dir-Pagination: <current>/<last>` header is also emitted.
|
|
143
|
+
|
|
144
|
+
**Migration from v2**
|
|
145
|
+
|
|
146
|
+
`showDirContents` (a v2-stable option) keeps working as a **backward-compatibility alias** for `dirListing.enabled`. v2 code that passes it continues to function unchanged. A one-time deprecation warning is emitted via the configured `logger.warn(...)` to encourage migration:
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
[koa-classic-server] DEPRECATION: options.showDirContents was renamed to dirListing.enabled in v3.0.0.
|
|
150
|
+
The old name is currently accepted as an alias and may be removed in a future major version.
|
|
151
|
+
Replace with: dirListing: { enabled: true }
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Passing both `showDirContents` and `dirListing.enabled` at the same time throws — pick one.
|
|
155
|
+
|
|
156
|
+
**Migration from v3.0.0-alpha.0**
|
|
157
|
+
|
|
158
|
+
The two V3-alpha-only legacy names throw helpful errors at startup (no v2 user can have these in production):
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
options.maxDirEntries was relocated in v3.0.0.
|
|
162
|
+
Replace with: dirListing: { maxEntries: 10000 }
|
|
163
|
+
|
|
164
|
+
options.pageSize was relocated and renamed in v3.0.0.
|
|
165
|
+
Replace with: dirListing: { entriesPerPage: 100 }
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
**CSP impact:** the listing CSS now includes rules for `.kcs-banner` and `.kcs-pagination`. The page's CSP hash is auto-recomputed at module load (no manual config change needed).
|
|
169
|
+
|
|
170
|
+
### 📝 Documentation
|
|
171
|
+
|
|
172
|
+
#### DNS Rebinding deployment guidance (Security M-3)
|
|
173
|
+
|
|
174
|
+
The `Host` header is intentionally not validated by the middleware — host validation belongs to the reverse proxy or to a dedicated application-level guard. The new *Best Practices → Sicurezza → DNS Rebinding* section in `docs/DOCUMENTATION.md` explains:
|
|
175
|
+
|
|
176
|
+
- When the risk applies (LAN/loopback exposure without a fronting proxy).
|
|
177
|
+
- When it doesn't (reverse proxy with `server_name` allowlist, public IP behind CDN/WAF).
|
|
178
|
+
- A drop-in nginx allowlist snippet.
|
|
179
|
+
- A Koa middleware that checks `ctx.host` against an allowlist and returns `421 Misdirected Request`, plus a note on `app.proxy = true` + `X-Forwarded-Host` when terminating TLS upstream.
|
|
180
|
+
|
|
181
|
+
No code change in `index.cjs` — documentation only.
|
|
182
|
+
|
|
183
|
+
#### Security headers scope and limits (Security M-4)
|
|
184
|
+
|
|
185
|
+
Clarify that the security headers emitted by the middleware (`Content-Security-Policy`, `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`) are applied **only** to middleware-generated responses (directory listing + error pages). User-served static files are returned without these headers — by design, because the right policy is application-specific.
|
|
186
|
+
|
|
187
|
+
The new *Best Practices → Sicurezza → Limiti dei Security Headers sui file statici* section in `docs/DOCUMENTATION.md` covers:
|
|
188
|
+
|
|
189
|
+
- A table listing which headers are emitted automatically and on which responses.
|
|
190
|
+
- An upstream Koa middleware example that adds `X-Content-Type-Options`, `Referrer-Policy`, `Strict-Transport-Security` to every response and a strict CSP only to HTML responses.
|
|
191
|
+
- Notes on rolling out CSP via `Content-Security-Policy-Report-Only`, and on `COOP`/`COEP` for projects using `SharedArrayBuffer`.
|
|
192
|
+
|
|
193
|
+
No code change in `index.cjs` — documentation only.
|
|
194
|
+
|
|
195
|
+
### 🎯 Design Philosophy
|
|
196
|
+
|
|
197
|
+
v3.0.0 codifies the project's design intent in a new top-level `CLAUDE.md`: **koa-classic-server is an HTTP file server first**. The contract with the operator is: *"if a file is in `rootDir`, `GET` on its path returns it"*. Defaults serve files without applying surprise restrictions — the operator's directory is the source of truth.
|
|
198
|
+
|
|
199
|
+
This drove a revision of two v3-alpha defaults late in the cycle (see *Breaking Changes* below) and shapes how new features will be designed going forward. Operators harden via explicit configuration; the README and `docs/DOCUMENTATION.md` now ship a **Security Checklist** and a **Suggested Production Security Configuration** to help with that.
|
|
200
|
+
|
|
52
201
|
### ⚠️ Breaking Changes
|
|
53
202
|
|
|
54
|
-
#### Dot-files
|
|
55
|
-
|
|
203
|
+
#### Dot-files visible by default (philosophy alignment)
|
|
204
|
+
|
|
205
|
+
Earlier in the v3.0.0 alpha cycle, `hidden.dotFiles.default` was flipped to `'hidden'` as a security-by-default choice. This created surprise behavior — `GET /.env` returning 404 even when the file exists — which violates the "file server first" design philosophy.
|
|
206
|
+
|
|
207
|
+
**Final v3.0.0 behavior:** `hidden.dotFiles.default` is `'visible'`, restoring v2 behavior. The implicit-default warning that fired in alpha when the option was omitted is also removed.
|
|
208
|
+
|
|
209
|
+
| Default | v2 | v3.0.0-alpha early | **v3.0.0 final** |
|
|
210
|
+
|---|---|---|---|
|
|
211
|
+
| `hidden.dotFiles.default` | `'visible'` | `'hidden'` | **`'visible'`** |
|
|
212
|
+
| Implicit-default runtime warning | — | emitted | **removed** |
|
|
213
|
+
|
|
214
|
+
**Operators upgrading from v2:** no change in behavior — your existing dot-files keep being served. **Migration to harden** (recommended for production): set `hidden.dotFiles.default: 'hidden'` explicitly and whitelist `.well-known` for ACME. See the *Security Checklist* in `README.md`.
|
|
215
|
+
|
|
216
|
+
#### `dirListing.maxEntries` default raised from 10,000 → 100,000
|
|
56
217
|
|
|
57
|
-
|
|
218
|
+
The earlier v3-alpha default of `10,000` was tight enough that operators with normal-sized media catalogs, releases archives, or asset directories would hit truncation silently (the listing banner would appear). This violated the "no surprise restrictions" rule — the cap was acting as a policy restriction rather than a safety net.
|
|
219
|
+
|
|
220
|
+
**Final v3.0.0 behavior:** `dirListing.maxEntries` defaults to `100,000` — high enough that 99% of legitimate deployments never hit it, low enough to bound rendering cost on accidentally-huge directories (log rotation broken, mistakenly mounted FS).
|
|
221
|
+
|
|
222
|
+
| Default | v3.0.0-alpha early | **v3.0.0 final** |
|
|
223
|
+
|---|---|---|
|
|
224
|
+
| `dirListing.maxEntries` | `10000` | **`100000`** |
|
|
225
|
+
| `dirListing.entriesPerPage` | `100` | `100` (unchanged) |
|
|
226
|
+
|
|
227
|
+
**Caveat:** even with `maxEntries: 100000`, the initial `fs.promises.readdir()` allocation is not bounded. For adversarial-directory workloads (multi-tenant uploads, untrusted writes), this gap will be closed by the v3.1 `dirListing.readMode: 'bounded'` option — tracked under `[F-1]` in `docs/security_improvement_for_V3.md`.
|
|
228
|
+
|
|
229
|
+
#### Dot-files hardening is now opt-in (was implicit "secure by default")
|
|
230
|
+
|
|
231
|
+
Operators who *want* the v3-alpha behavior (dot-files hidden by default, including `.env`, `.git/config`, etc.) must now opt in explicitly:
|
|
232
|
+
|
|
233
|
+
```javascript
|
|
234
|
+
app.use(koaClassicServer('/public', {
|
|
235
|
+
hidden: {
|
|
236
|
+
dotFiles: { default: 'hidden', whitelist: ['.well-known'] },
|
|
237
|
+
dotDirs: { default: 'hidden', whitelist: ['.well-known'] },
|
|
238
|
+
},
|
|
239
|
+
}));
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
This snippet now appears in the *Suggested Production Security Configuration* in both `README.md` and `docs/DOCUMENTATION.md`.
|
|
58
243
|
|
|
59
244
|
#### Removed string format for `index` option
|
|
60
245
|
- **Removed**: `index: 'index.html'` — passing a non-empty string now throws an `Error` at startup
|
|
@@ -87,6 +272,26 @@ To restore v2.x behavior: `hidden: { dotFiles: { default: 'visible' } }`
|
|
|
87
272
|
}));
|
|
88
273
|
```
|
|
89
274
|
|
|
275
|
+
#### Renamed `compression.minSize` → `compression.minFileSize`
|
|
276
|
+
|
|
277
|
+
The threshold below which files are served uncompressed has a clearer name. Brings naming into line with `serverCache.rawFile.maxFileSize`, where "file size" is the explicit unit. Affects only alpha-tester code (the `compression` namespace was introduced in v3 and is not present in v2).
|
|
278
|
+
|
|
279
|
+
- **Removed**: `compression.minSize` — passing it now throws an `Error` at startup
|
|
280
|
+
- **Migration**:
|
|
281
|
+
```js
|
|
282
|
+
// Before (v3.0.0-alpha.0 — now throws)
|
|
283
|
+
app.use(koaClassicServer('/public', {
|
|
284
|
+
compression: { minSize: 2048 }
|
|
285
|
+
}));
|
|
286
|
+
|
|
287
|
+
// After (v3.0.0)
|
|
288
|
+
app.use(koaClassicServer('/public', {
|
|
289
|
+
compression: { minFileSize: 2048 }
|
|
290
|
+
}));
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
The `false` shorthand (disable the threshold entirely) is preserved on the new name: `compression: { minFileSize: false }`.
|
|
294
|
+
|
|
90
295
|
---
|
|
91
296
|
|
|
92
297
|
## [2.6.1] - 2026-03-04
|
package/docs/CODE_REVIEW.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Code Review - koa-classic-server
|
|
2
2
|
|
|
3
|
+
> **Nota storica:** questo documento è uno snapshot di code review precedente al refactor V3. Riferimenti a `showDirContents` corrispondono a `dirListing.enabled` nell'API V3 corrente. Vedi [README.md → Migration Guide](../README.md#from-v2x-to-v3x).
|
|
4
|
+
|
|
3
5
|
## Analisi Generale del Codice
|
|
4
6
|
|
|
5
7
|
Data: 2025-11-18
|