koa-classic-server 3.0.1 → 4.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/README.md +186 -818
- package/index.cjs +837 -252
- package/package.json +12 -6
- package/.github/workflows/npm-publish.yml +0 -98
- package/CLAUDE.md +0 -101
- package/__tests__/benchmark-results-baseline-v1.2.0.txt +0 -354
- package/__tests__/benchmark-results-optimized-v2.0.0.txt +0 -354
- package/__tests__/benchmark-results-v3.0.0.txt +0 -372
- package/__tests__/benchmark.js +0 -239
- package/__tests__/caching-headers.test.js +0 -556
- package/__tests__/compression-fixtures/data.json +0 -1
- package/__tests__/compression-fixtures/large.txt +0 -1
- package/__tests__/compression-fixtures/small.txt +0 -1
- package/__tests__/compression.test.js +0 -284
- package/__tests__/customTest/README.md +0 -6
- package/__tests__/customTest/loadConfig.util.js +0 -41
- package/__tests__/customTest/serversToLoad.util.js +0 -93
- package/__tests__/demo-regex-index.js +0 -140
- package/__tests__/deprecation-warnings.test.js +0 -105
- package/__tests__/directory-sorting-links.test.js +0 -135
- package/__tests__/dt-unknown.test.js +0 -635
- package/__tests__/ejs.test.js +0 -299
- package/__tests__/head-method.test.js +0 -160
- package/__tests__/hidden-fixtures/.dot-dir/inside.txt +0 -1
- package/__tests__/hidden-fixtures/.env +0 -2
- package/__tests__/hidden-fixtures/.well-known/acme-challenge.txt +0 -1
- package/__tests__/hidden-fixtures/config/secrets/password.txt +0 -1
- package/__tests__/hidden-fixtures/data.key +0 -1
- package/__tests__/hidden-fixtures/file.secret +0 -1
- package/__tests__/hidden-fixtures/index.html +0 -1
- package/__tests__/hidden-fixtures/normal.txt +0 -1
- package/__tests__/hidden-fixtures/subdir/.env +0 -1
- package/__tests__/hidden-fixtures/subdir/regular.txt +0 -1
- package/__tests__/hidden-option.test.js +0 -407
- package/__tests__/hideExtension.test.js +0 -607
- package/__tests__/index-option.test.js +0 -449
- package/__tests__/index.test.js +0 -345
- package/__tests__/listing.test.js +0 -437
- package/__tests__/logger.test.js +0 -232
- package/__tests__/performance.test.js +0 -370
- package/__tests__/publicWwwTest/cartella/sottocartella/ciao.html +0 -11
- package/__tests__/publicWwwTest/cartella/sottocartella/provaEjs/testEjs.ejs +0 -11
- package/__tests__/publicWwwTest/cartella vuota con spazi nel nome/file con spazio nel nome .txt +0 -1
- package/__tests__/publicWwwTest/cartella vuota con spazi nel nome /file con spazio nel nome .txt +0 -1
- package/__tests__/publicWwwTest/ejs-templates/complex.ejs +0 -56
- package/__tests__/publicWwwTest/ejs-templates/index.ejs +0 -30
- package/__tests__/publicWwwTest/ejs-templates/simple.ejs +0 -13
- package/__tests__/publicWwwTest/ejs-templates/with-conditional.ejs +0 -28
- package/__tests__/publicWwwTest/ejs-templates/with-escaping.ejs +0 -26
- package/__tests__/publicWwwTest/ejs-templates/with-loop.ejs +0 -16
- package/__tests__/publicWwwTest/hideext-test/about/index.html +0 -1
- package/__tests__/publicWwwTest/hideext-test/about.EJS +0 -1
- package/__tests__/publicWwwTest/hideext-test/about.ejs +0 -2
- package/__tests__/publicWwwTest/hideext-test/blog/articolo.ejs +0 -2
- package/__tests__/publicWwwTest/hideext-test/conflict-test/pagina +0 -1
- package/__tests__/publicWwwTest/hideext-test/conflict-test/pagina.ejs +0 -1
- package/__tests__/publicWwwTest/hideext-test/file.ejs.bak +0 -1
- package/__tests__/publicWwwTest/hideext-test/index.ejs +0 -2
- package/__tests__/publicWwwTest/hideext-test/photo.txt +0 -1
- package/__tests__/publicWwwTest/hideext-test/sezione/index.ejs +0 -1
- package/__tests__/publicWwwTest/hideext-test/style.css +0 -1
- package/__tests__/publicWwwTest/ile_vuoto.txt +0 -0
- package/__tests__/publicWwwTest/index.html +0 -11
- package/__tests__/publicWwwTest/prova file .txt +0 -2
- package/__tests__/publicWwwTest/semplicetxt.txt +0 -1
- package/__tests__/publicWwwTest/test-page.html +0 -1
- package/__tests__/publicWwwTest/test.txt +0 -1
- package/__tests__/range-fixtures/sample.txt +0 -1
- package/__tests__/range.test.js +0 -223
- package/__tests__/security-headers.test.js +0 -165
- package/__tests__/security.test.js +0 -322
- package/__tests__/server-cache-fixtures/large.txt +0 -1
- package/__tests__/server-cache-fixtures/small.txt +0 -1
- package/__tests__/server-cache.test.js +0 -594
- package/__tests__/setup-benchmark.js +0 -178
- package/__tests__/symlink.test.js +0 -441
- package/__tests__/template-timeout.test.js +0 -321
- package/__tests__/test-regex-quick.js +0 -158
- package/__tests__/useOriginalUrl.test.js +0 -213
- package/docs/ACTION_PLAN.md +0 -293
- package/docs/BENCHMARKS.md +0 -317
- package/docs/CHANGELOG.md +0 -906
- package/docs/CODE_REVIEW.md +0 -300
- package/docs/DEBUG_REPORT.md +0 -593
- package/docs/DOCUMENTATION.md +0 -1864
- package/docs/EXAMPLES_INDEX_OPTION.md +0 -395
- package/docs/FLOW_DIAGRAM.md +0 -954
- package/docs/INDEX_OPTION_PRIORITY.md +0 -527
- package/docs/OPTIMIZATION_HTTP_CACHING.md +0 -689
- package/docs/OPTIMIZATION_ROADMAP_for_V3.md +0 -864
- package/docs/PERFORMANCE_ANALYSIS.md +0 -839
- package/docs/PERFORMANCE_COMPARISON.md +0 -388
- package/docs/noteExports.md +0 -148
- package/docs/security_improvement_for_V3.md +0 -421
- package/docs/template-engine/TEMPLATE_ENGINE_GUIDE.md +0 -1734
- package/docs/template-engine/esempi-incrementali.js +0 -192
- package/docs/template-engine/examples/esempio1-nessun-dato.ejs +0 -12
- package/docs/template-engine/examples/esempio2-una-variabile.ejs +0 -11
- package/docs/template-engine/examples/esempio3-piu-variabili.ejs +0 -15
- package/docs/template-engine/examples/esempio4-condizionale.ejs +0 -18
- package/docs/template-engine/examples/esempio5-loop.ejs +0 -18
- package/docs/template-engine/examples/index-esempi.html +0 -181
- package/docs/template-engine/examples/index.html +0 -40
- package/docs/template-engine/examples/test.ejs +0 -64
- package/eslint.config.mjs +0 -17
- package/jest.config.js +0 -18
package/index.cjs
CHANGED
|
@@ -6,7 +6,7 @@ const crypto = require("crypto");
|
|
|
6
6
|
const zlib = require("zlib");
|
|
7
7
|
const util = require("util");
|
|
8
8
|
const mime = require("mime-types");
|
|
9
|
-
const { Readable } = require('stream');
|
|
9
|
+
const { Readable, pipeline } = require('stream');
|
|
10
10
|
|
|
11
11
|
const _brotliCompressAsync = util.promisify(zlib.brotliCompress);
|
|
12
12
|
const _gzipAsync = util.promisify(zlib.gzip);
|
|
@@ -151,6 +151,7 @@ function buildErrorHtml(title, heading, message) {
|
|
|
151
151
|
const _NOT_FOUND_HTML = buildErrorHtml('URL not found', 'Not Found', 'The requested URL was not found on this server.');
|
|
152
152
|
const _GATEWAY_TIMEOUT_HTML = buildErrorHtml('Gateway Timeout', 'Gateway Timeout', 'The template took too long to render.');
|
|
153
153
|
const _TEMPLATE_ERROR_HTML = buildErrorHtml('Internal Server Error', 'Internal Server Error', 'Template rendering failed for the requested resource.');
|
|
154
|
+
const _INTERNAL_ERROR_HTML = buildErrorHtml('Internal Server Error', 'Internal Server Error', 'The server encountered an unexpected condition.');
|
|
154
155
|
|
|
155
156
|
function sendNotFound(ctx) {
|
|
156
157
|
setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
|
|
@@ -158,6 +159,14 @@ function sendNotFound(ctx) {
|
|
|
158
159
|
ctx.body = _NOT_FOUND_HTML;
|
|
159
160
|
}
|
|
160
161
|
|
|
162
|
+
// Plain-text 400 for malformed requests (bad percent-encoding, invalid Host,
|
|
163
|
+
// null byte). Kept minimal and header-light to match the existing null-byte /
|
|
164
|
+
// traversal guards — the response body carries no attacker-controlled data.
|
|
165
|
+
function sendBadRequest(ctx) {
|
|
166
|
+
ctx.status = 400;
|
|
167
|
+
ctx.body = 'Bad Request';
|
|
168
|
+
}
|
|
169
|
+
|
|
161
170
|
// Validates and returns a logger compatible with our contract. The minimum
|
|
162
171
|
// surface is `{ error: Function, warn: Function }` — any object exposing both
|
|
163
172
|
// (console, pino, winston, bunyan, ...) is accepted as-is.
|
|
@@ -184,6 +193,23 @@ function warnPayload(logger, message) {
|
|
|
184
193
|
: [message];
|
|
185
194
|
}
|
|
186
195
|
|
|
196
|
+
// Config-deprecation warnings, deduplicated once-per-process (per distinct
|
|
197
|
+
// message) so creating many middleware instances doesn't repeat the same nag —
|
|
198
|
+
// same intent as the _showDirContentsDeprecationWarned flag, generalized to
|
|
199
|
+
// multiple messages. These options (urlPrefix, urlsReserved, ...) are v2-stable,
|
|
200
|
+
// so a malformed value is TOLERATED with a warning for now rather than thrown:
|
|
201
|
+
// throwing on a stable option would be a breaking change on a minor upgrade.
|
|
202
|
+
// The next major will flip `warnConfigDeprecation` into a hard throw (the call
|
|
203
|
+
// sites already carry the final message) — see docs/revisione_codice_v3.1.md #11.
|
|
204
|
+
const _configDeprecationsWarned = new Set();
|
|
205
|
+
function warnConfigDeprecation(logger, message) {
|
|
206
|
+
if (_configDeprecationsWarned.has(message)) return;
|
|
207
|
+
_configDeprecationsWarned.add(message);
|
|
208
|
+
logger.warn(...warnPayload(logger,
|
|
209
|
+
'[koa-classic-server] DEPRECATION: ' + message +
|
|
210
|
+
'\n This is tolerated for now and WILL throw in a future major version.'));
|
|
211
|
+
}
|
|
212
|
+
|
|
187
213
|
// Sends an error response for a failed template render. If headers were already
|
|
188
214
|
// flushed by the render itself, destroys the underlying socket instead (the
|
|
189
215
|
// status/body can no longer be changed at that point).
|
|
@@ -379,6 +405,24 @@ function parseRangeHeader(rangeHeader, fileSize) {
|
|
|
379
405
|
return { start, end };
|
|
380
406
|
}
|
|
381
407
|
|
|
408
|
+
// Evaluate an If-None-Match header against our (strong) ETag, per RFC 9110 §13.1.2.
|
|
409
|
+
// The header is either "*" (matches any existing representation) or a comma-separated
|
|
410
|
+
// list of entity-tags. The comparison is the WEAK function: a leading "W/" is ignored
|
|
411
|
+
// on both sides (a proxy/CDN may have weakened the validator). Returns true when the
|
|
412
|
+
// precondition is satisfied, i.e. the client's cached representation is still current
|
|
413
|
+
// and the server should answer 304 (Not Modified) for a GET/HEAD.
|
|
414
|
+
function ifNoneMatchSatisfied(headerValue, etag) {
|
|
415
|
+
if (!headerValue) return false;
|
|
416
|
+
const trimmed = headerValue.trim();
|
|
417
|
+
if (trimmed === '*') return true; // "*" matches any existing representation
|
|
418
|
+
const target = etag.replace(/^W\//, ''); // our ETag is strong, but strip defensively
|
|
419
|
+
for (const part of trimmed.split(',')) {
|
|
420
|
+
const tag = part.trim().replace(/^W\//, '');
|
|
421
|
+
if (tag && tag === target) return true;
|
|
422
|
+
}
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
|
|
382
426
|
// LFU cache with O(1) eviction using frequency buckets.
|
|
383
427
|
// peek(key) — read without touching frequency (for staleness checks)
|
|
384
428
|
// get(key) — read and increment frequency
|
|
@@ -412,10 +456,17 @@ class LFUCache {
|
|
|
412
456
|
}
|
|
413
457
|
|
|
414
458
|
set(key, entry) {
|
|
459
|
+
// An entry larger than the whole cache can never fit: bail out BEFORE the
|
|
460
|
+
// eviction loop, otherwise it would flush every other entry for nothing.
|
|
461
|
+
// Warn (throttled) so the operator learns the cache is undersized for
|
|
462
|
+
// this file instead of it silently never being cached.
|
|
463
|
+
if (entry.buffer.length > this.maxSize) {
|
|
464
|
+
this._warnThrottled(`[koa-classic-server] serverCache.${this.cacheLabel}: entry of ${entry.buffer.length} bytes exceeds maxSize (${this.maxSize} bytes) and will never be cached. Consider increasing maxSize.`);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
415
467
|
while (this.currentSize + entry.buffer.length > this.maxSize && this._keyMap.size > 0) {
|
|
416
468
|
this._evictOne();
|
|
417
469
|
}
|
|
418
|
-
if (this.currentSize + entry.buffer.length > this.maxSize) return; // entry too large for cache
|
|
419
470
|
|
|
420
471
|
this._keyMap.set(key, { ...entry, freq: 1 });
|
|
421
472
|
this._addToFreqBucket(key, 1);
|
|
@@ -491,16 +542,37 @@ class LFUCache {
|
|
|
491
542
|
bucket.delete(evictKey);
|
|
492
543
|
if (bucket.size === 0) this._freqMap.delete(this._minFreq);
|
|
493
544
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
545
|
+
this._warnThrottled(`[koa-classic-server] serverCache.${this.cacheLabel}: maxSize reached, evicting LFU entries. Consider increasing maxSize.`);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Emits a warning at most once per warnInterval ms (0 = always, false = never).
|
|
549
|
+
_warnThrottled(message) {
|
|
550
|
+
if (this.warnInterval === false) return;
|
|
551
|
+
const now = Date.now();
|
|
552
|
+
if (now - this._lastWarnAt >= this.warnInterval) {
|
|
553
|
+
this.logger.warn(message);
|
|
554
|
+
this._lastWarnAt = now;
|
|
500
555
|
}
|
|
501
556
|
}
|
|
502
557
|
}
|
|
503
558
|
|
|
559
|
+
// Single-flight job map helper: joins the in-flight job for `key`, or starts
|
|
560
|
+
// `work()` as the leader. Concurrent callers share the same Promise — including
|
|
561
|
+
// a rejection, so on failure every waiter falls back together instead of
|
|
562
|
+
// re-running the work. The entry is removed as soon as the job settles
|
|
563
|
+
// (success or failure), so the next request after a failure retries from
|
|
564
|
+
// scratch and the map only ever holds jobs actually in progress.
|
|
565
|
+
function singleFlight(map, key, work) {
|
|
566
|
+
let job = map.get(key);
|
|
567
|
+
if (!job) {
|
|
568
|
+
job = work();
|
|
569
|
+
map.set(key, job);
|
|
570
|
+
const clean = () => map.delete(key);
|
|
571
|
+
job.then(clean, clean);
|
|
572
|
+
}
|
|
573
|
+
return job;
|
|
574
|
+
}
|
|
575
|
+
|
|
504
576
|
// Upserts a fresh entry into an LFUCache. When the previous entry was only
|
|
505
577
|
// stale-by-age (mtime + size unchanged), updates in place so the existing
|
|
506
578
|
// frequency counter survives — important for popular files refreshed by maxAge.
|
|
@@ -523,10 +595,20 @@ module.exports = function koaClassicServer(
|
|
|
523
595
|
opts STRUCTURE
|
|
524
596
|
opts = {
|
|
525
597
|
method: ['GET'], // Supported methods, otherwise next() will be called
|
|
598
|
+
symlinks: 'follow', // Symlink policy (V3.1+). Opt-in protection against symlink escape:
|
|
599
|
+
// 'follow' (default) — follow symlinks anywhere, incl.
|
|
600
|
+
// targets OUTSIDE rootDir. Zero overhead;
|
|
601
|
+
// historical behavior (no rootDir existence check).
|
|
602
|
+
// 'follow-within-root' — follow only while the resolved realpath stays
|
|
603
|
+
// inside rootDir; escaping links return 404.
|
|
604
|
+
// 'deny' — never follow a symlink resolved below rootDir
|
|
605
|
+
// (rootDir being a symlink itself is always allowed).
|
|
606
|
+
// Protected modes cost one fs.realpath() per served path and require
|
|
607
|
+
// rootDir to exist at factory time. See README "Security Checklist".
|
|
526
608
|
dirListing: { // Directory listing configuration (V3+).
|
|
527
609
|
enabled: true, // Render the directory listing HTML when no index file matches.
|
|
528
610
|
// Set to false to return 404 instead of a listing.
|
|
529
|
-
maxEntries:
|
|
611
|
+
maxEntries: 10000, // Soft cap on entries shown / sorted / stat'd per listing.
|
|
530
612
|
// Implementation: fs.promises.readdir() then slice(0, maxEntries).
|
|
531
613
|
// This is a SAFETY NET against catastrophic operational accidents
|
|
532
614
|
// (broken log rotation, mistakenly mounted huge FS) — not a policy
|
|
@@ -542,16 +624,36 @@ module.exports = function koaClassicServer(
|
|
|
542
624
|
// when visible entries > entriesPerPage. Page index via
|
|
543
625
|
// ?page=N (0-based); out-of-range values are clamped silently.
|
|
544
626
|
// Must be a finite integer >= 0; 0 = disabled (no pagination).
|
|
627
|
+
trailingSlash: true, // Canonical trailing-slash enforcement (V4). Default true:
|
|
628
|
+
// GET /dir (directory, no slash) → 301 redirect to /dir/
|
|
629
|
+
// GET /file/ (file, trailing slash) → 404
|
|
630
|
+
// so relative links in an index page resolve against the
|
|
631
|
+
// directory. Set false for the v3 behavior (serve directories
|
|
632
|
+
// and files regardless of the trailing slash).
|
|
545
633
|
},
|
|
546
|
-
index: [
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
634
|
+
index: [], // Index file name(s) - must be an ARRAY.
|
|
635
|
+
// Default: [] — no index file is looked up; directories always
|
|
636
|
+
// show the listing (when dirListing.enabled). Configure explicitly
|
|
637
|
+
// for the classic index-file behavior, e.g. ["index.html"].
|
|
638
|
+
// - Array of strings: ["index.html", "index.htm", "default.html"]
|
|
639
|
+
// - Array of RegExp: [/index\.html/i, /default\.(html|htm)/i]
|
|
640
|
+
// - Mixed array: ["index.html", /index\.[eE][jJ][sS]/]
|
|
641
|
+
// Priority is determined by array order (first match wins)
|
|
642
|
+
urlPrefix: "", // URL path prefix. Should start with "/" and NOT end with "/"
|
|
643
|
+
// (e.g. "/static"); "" disables the prefix. A malformed value
|
|
644
|
+
// is tolerated with a deprecation warning for now (behavior
|
|
645
|
+
// unchanged) and WILL throw in the next major version.
|
|
646
|
+
urlsReserved: [], // Reserved first-level paths passed through to next().
|
|
647
|
+
// Each entry should be a single first-level path: a leading
|
|
648
|
+
// "/" plus one segment, no further "/" (e.g. "/admin").
|
|
649
|
+
// Malformed entries are tolerated with a deprecation warning
|
|
650
|
+
// for now and WILL throw in the next major version (a
|
|
651
|
+
// non-string entry is dropped to avoid a per-request 500).
|
|
553
652
|
template: {
|
|
554
653
|
render: undefined, // Template rendering function: async (ctx, next, filePath, rawBuffer, signal) => {}
|
|
654
|
+
// rawBuffer (4th arg, may be null) is READ-ONLY: the same Buffer
|
|
655
|
+
// instance is shared with the server cache and with concurrent
|
|
656
|
+
// requests — mutating it corrupts other responses.
|
|
555
657
|
ext: [], // File extensions to process with template.render
|
|
556
658
|
renderTimeout: 30000, // Max ms allowed for template.render (number ≥ 0; 0 = disabled).
|
|
557
659
|
// On timeout responds 504 Gateway Timeout. The render receives an
|
|
@@ -610,9 +712,25 @@ module.exports = function koaClassicServer(
|
|
|
610
712
|
enabled: true, // master switch (false = disable all compression)
|
|
611
713
|
encodings: ['br', 'gzip'], // algorithms in priority order; [] = disable
|
|
612
714
|
minFileSize: 1024, // min file size in bytes to compress; false = no minimum
|
|
715
|
+
maxFileSize: 10485760, // max file size (bytes) for the buffered high-quality
|
|
716
|
+
// compression path (whole file in RAM → brotli Q11 →
|
|
717
|
+
// result cached). Default: 10 MB; false = no cap.
|
|
718
|
+
// Larger files are STILL compressed, but via the
|
|
719
|
+
// bounded-RAM streaming mode (brotli Q4 / gzip 6, no
|
|
720
|
+
// Content-Length, not cached). This is a SAFETY NET
|
|
721
|
+
// against unbounded RAM/CPU on huge compressible files
|
|
722
|
+
// (multi-GB logs/JSON/CSV), not a serving restriction.
|
|
613
723
|
mimeTypes: [], // compressible MIME types (replaces default list if provided)
|
|
614
724
|
},
|
|
615
725
|
// compression: false // shorthand to disable all compression
|
|
726
|
+
staticSecurityHeaders: { // Opt-in security headers on STATIC file responses (V3.1+).
|
|
727
|
+
nosniff: false, // false (default) — no change. true → sets
|
|
728
|
+
// 'X-Content-Type-Options: nosniff' on 200/206/304 static
|
|
729
|
+
// responses, stopping MIME sniffing (content-sniffing XSS on
|
|
730
|
+
// user-uploaded files). Does NOT apply to template-rendered
|
|
731
|
+
// output. Off by default: hardening is opt-in. Other headers
|
|
732
|
+
// (X-Frame-Options, HSTS, ...) stay with the reverse proxy.
|
|
733
|
+
},
|
|
616
734
|
logger: console, // Logger used for internal errors and warnings.
|
|
617
735
|
// Must expose error(...) and warn(...). Pass pino/winston/bunyan
|
|
618
736
|
// or any compatible object to integrate with aggregated logging.
|
|
@@ -630,8 +748,31 @@ module.exports = function koaClassicServer(
|
|
|
630
748
|
|
|
631
749
|
const normalizedRootDir = path.resolve(rootDir);
|
|
632
750
|
|
|
633
|
-
|
|
634
|
-
|
|
751
|
+
// Options must be a plain object, or omitted entirely (the `opts = {}`
|
|
752
|
+
// default covers undefined). An explicit null — or any other non-object —
|
|
753
|
+
// is a configuration bug: surface it with a helpful error instead of a raw
|
|
754
|
+
// TypeError further down (or, worse, a silent fall-through to defaults).
|
|
755
|
+
if (opts === null || typeof opts !== 'object' || Array.isArray(opts)) {
|
|
756
|
+
throw new Error(
|
|
757
|
+
'[koa-classic-server] options must be a plain object (or omitted entirely). Got: ' +
|
|
758
|
+
(opts === null ? 'null' : Array.isArray(opts) ? 'an array' : typeof opts)
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Work on a copy: the factory normalizes options in place and must never
|
|
763
|
+
// mutate the caller's configuration object (reusing one config for two
|
|
764
|
+
// instances, or inspecting it after startup, would otherwise observe the
|
|
765
|
+
// rewritten values). Only the two nested objects the normalization writes
|
|
766
|
+
// into need their own copy — template (render/ext/renderTimeout) and
|
|
767
|
+
// hideExtension (ext/redirect); every other namespace is only read and
|
|
768
|
+
// normalized into new internal structures.
|
|
769
|
+
const options = { ...opts };
|
|
770
|
+
options.template = (opts.template && typeof opts.template === 'object' && !Array.isArray(opts.template))
|
|
771
|
+
? { ...opts.template }
|
|
772
|
+
: {};
|
|
773
|
+
if (options.hideExtension && typeof options.hideExtension === 'object' && !Array.isArray(options.hideExtension)) {
|
|
774
|
+
options.hideExtension = { ...options.hideExtension };
|
|
775
|
+
}
|
|
635
776
|
|
|
636
777
|
const _logger = normalizeLogger(options.logger);
|
|
637
778
|
|
|
@@ -706,6 +847,15 @@ module.exports = function koaClassicServer(
|
|
|
706
847
|
'dirListing.entriesPerPage',
|
|
707
848
|
100
|
|
708
849
|
),
|
|
850
|
+
// Canonical trailing-slash enforcement (V4). Default ON:
|
|
851
|
+
// - GET /dir (directory, no slash) → 301 redirect to /dir/
|
|
852
|
+
// - GET /file/ (file, trailing slash) → 404
|
|
853
|
+
// so relative links in an index page resolve against the directory and
|
|
854
|
+
// a file is only reachable at its slash-less URL. Set false to keep the
|
|
855
|
+
// v3 behavior (serve directories and files regardless of trailing slash).
|
|
856
|
+
trailingSlash: userDirListing && userDirListing.trailingSlash !== undefined
|
|
857
|
+
? !!userDirListing.trailingSlash
|
|
858
|
+
: true,
|
|
709
859
|
};
|
|
710
860
|
|
|
711
861
|
// Normalize index option to array format
|
|
@@ -729,9 +879,68 @@ module.exports = function koaClassicServer(
|
|
|
729
879
|
options.index = [];
|
|
730
880
|
}
|
|
731
881
|
|
|
732
|
-
|
|
882
|
+
// ── urlPrefix / urlsReserved validation (V3.1, deprecation-warn) ──
|
|
883
|
+
// The request-time matcher depends on an implicit format for both options,
|
|
884
|
+
// and a malformed value fails SILENTLY: a urlPrefix with a stray
|
|
885
|
+
// leading/trailing slash makes the middleware serve nothing (it always falls
|
|
886
|
+
// through to next()), and a urlsReserved entry without a leading slash makes
|
|
887
|
+
// the reservation never match (the path is served instead of passed on).
|
|
888
|
+
// Both are v2-stable options, so instead of throwing (a breaking change on a
|
|
889
|
+
// minor upgrade — a mis-slashed value that "worked" only by falling through
|
|
890
|
+
// to a downstream handler would suddenly change behavior), we WARN and leave
|
|
891
|
+
// the runtime behavior exactly as it is today. The next major turns these
|
|
892
|
+
// warnings into throws. The one exception is a non-string urlsReserved entry:
|
|
893
|
+
// it would 500 on every request (value.substring is not a function), which is
|
|
894
|
+
// not working behavior, so it is dropped defensively (still warned).
|
|
895
|
+
if (options.urlPrefix === undefined) {
|
|
896
|
+
options.urlPrefix = "";
|
|
897
|
+
} else if (typeof options.urlPrefix !== 'string') {
|
|
898
|
+
// Unchanged behavior: pre-existing code already coerced non-string → "".
|
|
899
|
+
warnConfigDeprecation(_logger,
|
|
900
|
+
'urlPrefix should be a string like "/static" (or "" for no prefix); got ' +
|
|
901
|
+
(options.urlPrefix === null ? 'null' : typeof options.urlPrefix) + ' — treating it as "".');
|
|
902
|
+
options.urlPrefix = "";
|
|
903
|
+
} else if (options.urlPrefix !== "" && (!options.urlPrefix.startsWith('/') || options.urlPrefix.endsWith('/'))) {
|
|
904
|
+
// Left as-is: the matcher behaves exactly as today (falls through to
|
|
905
|
+
// next() under this prefix). Warn only — no behavior change.
|
|
906
|
+
warnConfigDeprecation(_logger,
|
|
907
|
+
'urlPrefix should start with "/" and not end with "/" (use "" to disable); got ' +
|
|
908
|
+
JSON.stringify(options.urlPrefix) + ' — it will not route correctly until corrected.');
|
|
909
|
+
}
|
|
733
910
|
const _urlPrefixParts = options.urlPrefix.split("/");
|
|
734
|
-
|
|
911
|
+
|
|
912
|
+
if (options.urlsReserved === undefined) {
|
|
913
|
+
options.urlsReserved = [];
|
|
914
|
+
} else if (!Array.isArray(options.urlsReserved)) {
|
|
915
|
+
// Unchanged behavior: pre-existing code already coerced non-array → [].
|
|
916
|
+
warnConfigDeprecation(_logger,
|
|
917
|
+
'urlsReserved should be an array of first-level paths like ["/admin"]; got ' +
|
|
918
|
+
(options.urlsReserved === null ? 'null' : typeof options.urlsReserved) + ' — treating it as [].');
|
|
919
|
+
options.urlsReserved = [];
|
|
920
|
+
} else {
|
|
921
|
+
const cleaned = [];
|
|
922
|
+
for (const value of options.urlsReserved) {
|
|
923
|
+
if (typeof value !== 'string') {
|
|
924
|
+
// Dropped defensively: a non-string entry would throw at match
|
|
925
|
+
// time (value.substring is not a function) → a 500 on every
|
|
926
|
+
// request. Dropping it can't break working code.
|
|
927
|
+
warnConfigDeprecation(_logger,
|
|
928
|
+
'urlsReserved entries must be strings like "/admin"; dropping a non-string (' +
|
|
929
|
+
(value === null ? 'null' : typeof value) + ') entry.');
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
// Malformed but non-crashing (missing leading slash, extra segment,
|
|
933
|
+
// trailing slash, empty): kept as-is so the matcher behaves exactly
|
|
934
|
+
// as today (it simply won't match). Warn only.
|
|
935
|
+
if (value === '' || !value.startsWith('/') || value.indexOf('/', 1) !== -1) {
|
|
936
|
+
warnConfigDeprecation(_logger,
|
|
937
|
+
'each urlsReserved entry should be a single first-level path — a leading "/" plus one ' +
|
|
938
|
+
'segment, e.g. "/admin"; got ' + JSON.stringify(value) + ' — it will not match until corrected.');
|
|
939
|
+
}
|
|
940
|
+
cleaned.push(value);
|
|
941
|
+
}
|
|
942
|
+
options.urlsReserved = cleaned;
|
|
943
|
+
}
|
|
735
944
|
options.template.render = (options.template.render === undefined || typeof options.template.render === 'function') ? options.template.render : undefined;
|
|
736
945
|
options.template.ext = Array.isArray(options.template.ext) ? options.template.ext : [];
|
|
737
946
|
|
|
@@ -762,7 +971,21 @@ module.exports = function koaClassicServer(
|
|
|
762
971
|
);
|
|
763
972
|
}
|
|
764
973
|
|
|
765
|
-
|
|
974
|
+
// browserCacheMaxAge: non-negative integer seconds. An invalid value (negative, NaN,
|
|
975
|
+
// non-integer, Infinity, or a string) previously fell back to 3600 SILENTLY (#12).
|
|
976
|
+
// Consistent with #11: warn now and keep the fallback; a future major will throw
|
|
977
|
+
// (validateNonNegativeInt semantics) — so what warns here is exactly what will throw then.
|
|
978
|
+
if (options.browserCacheMaxAge === undefined) {
|
|
979
|
+
options.browserCacheMaxAge = 3600;
|
|
980
|
+
} else if (!(typeof options.browserCacheMaxAge === 'number'
|
|
981
|
+
&& Number.isInteger(options.browserCacheMaxAge)
|
|
982
|
+
&& options.browserCacheMaxAge >= 0)) {
|
|
983
|
+
warnConfigDeprecation(_logger,
|
|
984
|
+
'browserCacheMaxAge must be a non-negative integer (seconds). Got: ' +
|
|
985
|
+
String(options.browserCacheMaxAge) + '. Falling back to the default 3600 for now.');
|
|
986
|
+
options.browserCacheMaxAge = 3600;
|
|
987
|
+
}
|
|
988
|
+
// else: valid — keep as-is
|
|
766
989
|
options.browserCacheEnabled = typeof options.browserCacheEnabled === 'boolean' ? options.browserCacheEnabled : false;
|
|
767
990
|
options.useOriginalUrl = typeof options.useOriginalUrl === 'boolean' ? options.useOriginalUrl : true;
|
|
768
991
|
|
|
@@ -986,6 +1209,7 @@ module.exports = function koaClassicServer(
|
|
|
986
1209
|
enabled: true,
|
|
987
1210
|
encodings: ['br', 'gzip'], // priority order: brotli first, gzip as fallback
|
|
988
1211
|
minFileSize: 1024, // bytes; skip compression for files smaller than this
|
|
1212
|
+
maxFileSize: 10485760, // bytes; above this the buffered+cached path is skipped (streaming instead)
|
|
989
1213
|
mimeTypes: new Set(DEFAULT_COMPRESSIBLE_MIME_TYPES),
|
|
990
1214
|
};
|
|
991
1215
|
}
|
|
@@ -1008,11 +1232,17 @@ module.exports = function koaClassicServer(
|
|
|
1008
1232
|
const minFileSize = compression.minFileSize === false ? false
|
|
1009
1233
|
: (typeof compression.minFileSize === 'number' && compression.minFileSize >= 0 ? compression.minFileSize : 1024);
|
|
1010
1234
|
|
|
1235
|
+
// Cap for the buffered (whole-file-in-RAM, max-quality, cached) compression
|
|
1236
|
+
// path. false = no cap. Files above the cap still get compressed via the
|
|
1237
|
+
// bounded-RAM streaming mode — safety net, not a serving restriction.
|
|
1238
|
+
const maxFileSize = compression.maxFileSize === false ? false
|
|
1239
|
+
: (typeof compression.maxFileSize === 'number' && compression.maxFileSize > 0 ? compression.maxFileSize : 10485760);
|
|
1240
|
+
|
|
1011
1241
|
const mimeTypes = Array.isArray(compression.mimeTypes) && compression.mimeTypes.length > 0
|
|
1012
1242
|
? compression.mimeTypes
|
|
1013
1243
|
: DEFAULT_COMPRESSIBLE_MIME_TYPES;
|
|
1014
1244
|
|
|
1015
|
-
return { enabled, encodings, minFileSize, mimeTypes: new Set(mimeTypes) };
|
|
1245
|
+
return { enabled, encodings, minFileSize, maxFileSize, mimeTypes: new Set(mimeTypes) };
|
|
1016
1246
|
}
|
|
1017
1247
|
|
|
1018
1248
|
// Normalize and validate the serverCache option into a clean internal structure.
|
|
@@ -1066,6 +1296,100 @@ module.exports = function koaClassicServer(
|
|
|
1066
1296
|
return { rawFile, compressedFile };
|
|
1067
1297
|
}
|
|
1068
1298
|
|
|
1299
|
+
// ── symlinks policy (V3.1+) — opt-in protection against symlink escape ──
|
|
1300
|
+
// 'follow' (default) : follow symlinks anywhere, including targets
|
|
1301
|
+
// outside rootDir. Zero overhead — current behavior.
|
|
1302
|
+
// 'follow-within-root' : follow symlinks only while the resolved realpath
|
|
1303
|
+
// stays inside rootDir; escaping links return 404.
|
|
1304
|
+
// 'deny' : never follow a symlink resolved BELOW rootDir
|
|
1305
|
+
// (rootDir itself being a symlink is always allowed).
|
|
1306
|
+
// Protected modes cost one fs.realpath() per served path. See the Security Checklist.
|
|
1307
|
+
if (options.symlinks === undefined) {
|
|
1308
|
+
options.symlinks = 'follow';
|
|
1309
|
+
} else if (
|
|
1310
|
+
options.symlinks !== 'follow' &&
|
|
1311
|
+
options.symlinks !== 'follow-within-root' &&
|
|
1312
|
+
options.symlinks !== 'deny'
|
|
1313
|
+
) {
|
|
1314
|
+
throw new Error(
|
|
1315
|
+
'[koa-classic-server] options.symlinks must be one of "follow", "follow-within-root", "deny". ' +
|
|
1316
|
+
'Got: ' + String(options.symlinks)
|
|
1317
|
+
);
|
|
1318
|
+
}
|
|
1319
|
+
const _symlinkMode = options.symlinks;
|
|
1320
|
+
|
|
1321
|
+
// realpath of rootDir, resolved once at init (pinned). This is what makes a
|
|
1322
|
+
// rootDir that is ITSELF a symlink work in protected modes: the boundary check
|
|
1323
|
+
// compares against the real target, not the (unresolved) symlink path.
|
|
1324
|
+
// In 'follow' mode we skip this entirely (and keep the historical behavior of
|
|
1325
|
+
// NOT requiring rootDir to exist at factory time).
|
|
1326
|
+
let realRootDir = normalizedRootDir;
|
|
1327
|
+
if (_symlinkMode !== 'follow') {
|
|
1328
|
+
try {
|
|
1329
|
+
realRootDir = fs.realpathSync.native(normalizedRootDir);
|
|
1330
|
+
} catch (err) {
|
|
1331
|
+
throw new Error(
|
|
1332
|
+
`[koa-classic-server] rootDir must exist when symlinks mode is "${_symlinkMode}". ` +
|
|
1333
|
+
'Original error: ' + err.message
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// Case-insensitive path comparison on filesystems that are case-insensitive by
|
|
1339
|
+
// default (APFS/HFS+ on macOS, NTFS on Windows) — otherwise a casing mismatch
|
|
1340
|
+
// between realRootDir and a resolved path would produce spurious 404s.
|
|
1341
|
+
const _caseInsensitiveFS = process.platform === 'darwin' || process.platform === 'win32';
|
|
1342
|
+
|
|
1343
|
+
function _isWithinRoot(resolved, root) {
|
|
1344
|
+
if (_caseInsensitiveFS) {
|
|
1345
|
+
resolved = resolved.toLowerCase();
|
|
1346
|
+
root = root.toLowerCase();
|
|
1347
|
+
}
|
|
1348
|
+
if (resolved === root) return true;
|
|
1349
|
+
const withSep = root.endsWith(path.sep) ? root : root + path.sep;
|
|
1350
|
+
return resolved.startsWith(withSep);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// Returns true if serving `resolvedPath` is permitted under the current symlinks
|
|
1354
|
+
// policy. In 'follow' mode this is a no-op (no syscall). In protected modes it
|
|
1355
|
+
// resolves the realpath and checks it against realRootDir.
|
|
1356
|
+
async function symlinkAllowed(resolvedPath) {
|
|
1357
|
+
if (_symlinkMode === 'follow') return true;
|
|
1358
|
+
let real;
|
|
1359
|
+
try {
|
|
1360
|
+
real = await fs.promises.realpath(resolvedPath);
|
|
1361
|
+
} catch {
|
|
1362
|
+
return false; // cannot resolve (broken/circular link, race) → treat as not found
|
|
1363
|
+
}
|
|
1364
|
+
if (_symlinkMode === 'deny') {
|
|
1365
|
+
// Reject if ANY symlink was resolved below rootDir: the real path must equal
|
|
1366
|
+
// the path obtained by swapping only the rootDir prefix (no inner resolution).
|
|
1367
|
+
const rel = path.relative(normalizedRootDir, resolvedPath);
|
|
1368
|
+
const expected = path.join(realRootDir, rel);
|
|
1369
|
+
return _caseInsensitiveFS
|
|
1370
|
+
? real.toLowerCase() === expected.toLowerCase()
|
|
1371
|
+
: real === expected;
|
|
1372
|
+
}
|
|
1373
|
+
// 'follow-within-root': the resolved target must stay inside rootDir.
|
|
1374
|
+
return _isWithinRoot(real, realRootDir);
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// ── staticSecurityHeaders (V3.1+) — opt-in security headers on static responses ──
|
|
1378
|
+
// Off by default (design philosophy: hardening is opt-in, documentation over defaults).
|
|
1379
|
+
// Currently supports `nosniff` (X-Content-Type-Options: nosniff), which stops browsers
|
|
1380
|
+
// from MIME-sniffing a response and interpreting it against the declared Content-Type
|
|
1381
|
+
// (a content-sniffing XSS vector when serving user-uploaded files). Other headers
|
|
1382
|
+
// (X-Frame-Options, Referrer-Policy, HSTS) remain the reverse proxy's responsibility.
|
|
1383
|
+
const _ssh = options.staticSecurityHeaders;
|
|
1384
|
+
if (_ssh !== undefined && (typeof _ssh !== 'object' || _ssh === null || Array.isArray(_ssh))) {
|
|
1385
|
+
throw new Error(
|
|
1386
|
+
'[koa-classic-server] options.staticSecurityHeaders must be an object, e.g. { nosniff: true }.'
|
|
1387
|
+
);
|
|
1388
|
+
}
|
|
1389
|
+
const staticSecurityHeaders = {
|
|
1390
|
+
nosniff: !!(_ssh && _ssh.nosniff),
|
|
1391
|
+
};
|
|
1392
|
+
|
|
1069
1393
|
const compressionConfig = normalizeCompressionConfig(options.compression);
|
|
1070
1394
|
const serverCacheConfig = normalizeServerCacheConfig(options.serverCache);
|
|
1071
1395
|
|
|
@@ -1087,12 +1411,40 @@ module.exports = function koaClassicServer(
|
|
|
1087
1411
|
_logger
|
|
1088
1412
|
);
|
|
1089
1413
|
|
|
1090
|
-
//
|
|
1091
|
-
//
|
|
1414
|
+
// Single-flight maps for cache population (thundering-herd protection):
|
|
1415
|
+
// N concurrent misses on the same key share ONE read (+ compression) instead
|
|
1416
|
+
// of N duplicated jobs. Entries live only while a job is in flight. Keys
|
|
1417
|
+
// include the stat'd mtime+size so requests that observed different versions
|
|
1418
|
+
// of the file never share a job (each response stays coherent with its own
|
|
1419
|
+
// ETag/Last-Modified).
|
|
1420
|
+
const _inflightRawReads = new Map(); // `${path}:${mtime}:${size}` → Promise<Buffer>
|
|
1421
|
+
const _inflightCompressions = new Map(); // `${path}:${encoding}:${mtime}:${size}` → Promise<Buffer>
|
|
1422
|
+
|
|
1423
|
+
// Returns the server-preferred enabled encoding the client is willing to accept.
|
|
1424
|
+
// Server preference order (compressionConfig.encodings) still wins; the
|
|
1425
|
+
// Accept-Encoding q-values are used only to EXCLUDE encodings the client refuses
|
|
1426
|
+
// (q=0), per RFC 9110 §12.5.3. Token match is exact, not substring ("x-gzip" is
|
|
1427
|
+
// NOT "gzip"). A "*" supplies the q-value for any encoding not explicitly listed.
|
|
1092
1428
|
function getClientEncoding(acceptEncoding) {
|
|
1093
1429
|
if (!acceptEncoding) return null;
|
|
1430
|
+
const qValues = new Map(); // token → q-value
|
|
1431
|
+
for (const part of acceptEncoding.split(',')) {
|
|
1432
|
+
const [tokenRaw, ...params] = part.split(';');
|
|
1433
|
+
const token = tokenRaw.trim().toLowerCase();
|
|
1434
|
+
if (!token) continue;
|
|
1435
|
+
let q = 1;
|
|
1436
|
+
for (const p of params) {
|
|
1437
|
+
const m = /^\s*q=(\d+(?:\.\d+)?)\s*$/i.exec(p);
|
|
1438
|
+
if (m) q = parseFloat(m[1]);
|
|
1439
|
+
}
|
|
1440
|
+
qValues.set(token, q);
|
|
1441
|
+
}
|
|
1442
|
+
const star = qValues.get('*');
|
|
1094
1443
|
for (const enc of compressionConfig.encodings) {
|
|
1095
|
-
|
|
1444
|
+
let q = qValues.get(enc);
|
|
1445
|
+
if (q === undefined) q = star; // not listed → fall back to "*" if present
|
|
1446
|
+
if (q === undefined) continue; // not listed and no "*" → not offered
|
|
1447
|
+
if (q > 0) return enc; // acceptable → pick it (server preference order)
|
|
1096
1448
|
}
|
|
1097
1449
|
return null;
|
|
1098
1450
|
}
|
|
@@ -1188,11 +1540,26 @@ module.exports = function koaClassicServer(
|
|
|
1188
1540
|
const urlToUse = options.useOriginalUrl ? ctx.originalUrl : ctx.url;
|
|
1189
1541
|
const _origin = ctx.protocol + '://' + ctx.host;
|
|
1190
1542
|
const fullUrl = _origin + urlToUse;
|
|
1543
|
+
|
|
1544
|
+
// Whether the client's request path ends with "/" — captured from the
|
|
1545
|
+
// raw originalUrl BEFORE the trailing slash is stripped for URL parsing
|
|
1546
|
+
// below. Drives the canonical trailing-slash redirect / 404 in the
|
|
1547
|
+
// directory / file branch. "/" (root) counts as ending with a slash, so
|
|
1548
|
+
// it is already canonical and never redirects.
|
|
1549
|
+
const _rawOriginalPath = ctx.originalUrl.split('?')[0];
|
|
1550
|
+
const _pathEndsWithSlash = _rawOriginalPath.endsWith('/');
|
|
1551
|
+
// Parse the request URL. `new URL()` throws on an invalid Host header
|
|
1552
|
+
// (e.g. "Host: bad host") — reject as 400 rather than letting it surface as 500.
|
|
1191
1553
|
let pageHref = '';
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1554
|
+
try {
|
|
1555
|
+
if (fullUrl.charAt(fullUrl.length - 1) === '/') {
|
|
1556
|
+
pageHref = new URL(fullUrl.slice(0, -1));
|
|
1557
|
+
} else {
|
|
1558
|
+
pageHref = new URL(fullUrl);
|
|
1559
|
+
}
|
|
1560
|
+
} catch {
|
|
1561
|
+
sendBadRequest(ctx);
|
|
1562
|
+
return;
|
|
1196
1563
|
}
|
|
1197
1564
|
|
|
1198
1565
|
// Check URL prefix
|
|
@@ -1211,7 +1578,12 @@ module.exports = function koaClassicServer(
|
|
|
1211
1578
|
let a_pathnameOutPrefix = a_pathname.slice(_urlPrefixParts.length);
|
|
1212
1579
|
let s_pathnameOutPrefix = a_pathnameOutPrefix.join("/");
|
|
1213
1580
|
let hrefOutPrefix = pageHref.origin + '/' + s_pathnameOutPrefix;
|
|
1214
|
-
|
|
1581
|
+
try {
|
|
1582
|
+
pageHrefOutPrefix = new URL(hrefOutPrefix);
|
|
1583
|
+
} catch {
|
|
1584
|
+
sendBadRequest(ctx);
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1215
1587
|
}
|
|
1216
1588
|
|
|
1217
1589
|
// Check reserved URLs (first level only)
|
|
@@ -1225,167 +1597,291 @@ module.exports = function koaClassicServer(
|
|
|
1225
1597
|
}
|
|
1226
1598
|
}
|
|
1227
1599
|
|
|
1228
|
-
//
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1600
|
+
// From this point on the middleware OWNS the request: every early
|
|
1601
|
+
// pass-through above has already returned, and no next() is called below
|
|
1602
|
+
// (the template render's next goes through tryRenderTemplate's own catch).
|
|
1603
|
+
// Last-resort net: an unexpected failure on any unguarded path must not
|
|
1604
|
+
// leak to Koa's default handler (a plain-text 500 without the middleware's
|
|
1605
|
+
// security headers, logged outside the operator's `logger`). Errors from
|
|
1606
|
+
// downstream middleware are NOT masked — they never reach this try.
|
|
1607
|
+
try {
|
|
1608
|
+
// Path traversal protection: build and validate safe file path
|
|
1609
|
+
let requestedPath = "";
|
|
1610
|
+
if (pageHrefOutPrefix.pathname === "/") {
|
|
1611
|
+
requestedPath = "";
|
|
1612
|
+
} else {
|
|
1613
|
+
// decodeURIComponent() throws URIError on malformed percent-encoding
|
|
1614
|
+
// (e.g. "/%", "/%zz", a truncated UTF-8 sequence) — reject as 400
|
|
1615
|
+
// rather than letting it surface as an unhandled 500.
|
|
1616
|
+
try {
|
|
1617
|
+
requestedPath = decodeURIComponent(pageHrefOutPrefix.pathname);
|
|
1618
|
+
} catch {
|
|
1619
|
+
sendBadRequest(ctx);
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1243
1623
|
|
|
1244
|
-
|
|
1245
|
-
|
|
1624
|
+
// Null byte guard: path.normalize() throws ERR_INVALID_ARG_VALUE for paths
|
|
1625
|
+
// containing \0. Reject early with 400 Bad Request before it reaches fs calls.
|
|
1626
|
+
if (requestedPath.includes('\0')) {
|
|
1627
|
+
sendBadRequest(ctx);
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1246
1630
|
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1631
|
+
const normalizedPath = path.normalize(requestedPath);
|
|
1632
|
+
const fullPath = path.join(normalizedRootDir, normalizedPath);
|
|
1633
|
+
|
|
1634
|
+
// Security check: ensure resolved path is within rootDir. Uses the shared
|
|
1635
|
+
// _isWithinRoot() helper, which is boundary-aware: it matches rootDir exactly
|
|
1636
|
+
// or rootDir + path.sep, never a sibling (e.g. /srv/wwwsecret for root /srv/www) —
|
|
1637
|
+
// hardened defense in depth against a future change to how fullPath is built.
|
|
1638
|
+
// Covers: ../ traversal, URL-encoded variants (%2e%2e%2f), and on Windows
|
|
1639
|
+
// backslash sequences (path.normalize converts \ to / before the check).
|
|
1640
|
+
// Returns 404 (not 403) so "outside root" is indistinguishable from "not found",
|
|
1641
|
+
// matching the symlink-escape and hidden-entry outcomes.
|
|
1642
|
+
if (!_isWithinRoot(fullPath, normalizedRootDir)) {
|
|
1643
|
+
sendNotFound(ctx);
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1255
1646
|
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1647
|
+
// Hidden check: block requests that traverse a hidden directory.
|
|
1648
|
+
// Stops at length-1 because the leaf (the file or dir being served) is
|
|
1649
|
+
// checked separately by the file/listing path with its real stat.isDirectory().
|
|
1650
|
+
if (requestedPath !== '') {
|
|
1651
|
+
const segments = normalizedPath.split(path.sep).filter(Boolean);
|
|
1652
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
1653
|
+
const segName = segments[i];
|
|
1654
|
+
const segRelPath = segments.slice(0, i + 1).join('/');
|
|
1655
|
+
if (isHiddenEntry(segName, segRelPath, true)) {
|
|
1656
|
+
sendNotFound(ctx);
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1267
1659
|
}
|
|
1268
1660
|
}
|
|
1269
|
-
}
|
|
1270
1661
|
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
//
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1662
|
+
let toOpen = fullPath;
|
|
1663
|
+
|
|
1664
|
+
// hideExtension logic: redirect URLs with extension and resolve clean URLs
|
|
1665
|
+
if (options.hideExtension) {
|
|
1666
|
+
const hideExt = options.hideExtension.ext;
|
|
1667
|
+
const hideRedirect = options.hideExtension.redirect;
|
|
1668
|
+
|
|
1669
|
+
// Trailing slash check via string — avoids a full new URL() construction
|
|
1670
|
+
const rawPath = urlToUse.split('?')[0];
|
|
1671
|
+
const hadTrailingSlash = rawPath.length > 1 && rawPath.endsWith('/');
|
|
1672
|
+
|
|
1673
|
+
// Model B (V4): an extension URL is canonicalized to its clean form only when it
|
|
1674
|
+
// has NO trailing slash. A trailing slash means directory intent, so /foo.ejs/
|
|
1675
|
+
// falls through to the file/dir dispatch, where a file requested with a trailing
|
|
1676
|
+
// slash is a 404 (finding #3). We gate on _pathEndsWithSlash — the very flag that
|
|
1677
|
+
// #3's file-branch 404 uses — so "skip the redirect" and "404 downstream" are the
|
|
1678
|
+
// same condition. requestedPath is decoded (and already slash-stripped by the URL
|
|
1679
|
+
// parse), so a percent-encoded dot (/foo%2Eejs → /foo.ejs) matches consistently (#14),
|
|
1680
|
+
// while /foo%2Eejs/ is excluded here by _pathEndsWithSlash and 404s downstream.
|
|
1681
|
+
if (!_pathEndsWithSlash && requestedPath.endsWith(hideExt)) {
|
|
1682
|
+
// Build redirect target using ctx.originalUrl (always, regardless of
|
|
1683
|
+
// useOriginalUrl). With useOriginalUrl: false the URL-parsing prologue
|
|
1684
|
+
// validated ctx.url (the rewritten one), NOT originalUrl — a malformed
|
|
1685
|
+
// originalUrl (e.g. an absolute-form request target, legal in HTTP/1.1)
|
|
1686
|
+
// makes this constructor throw. Same treatment as the other malformed
|
|
1687
|
+
// client input: 400 Bad Request, not an unhandled error.
|
|
1688
|
+
let originalUrlObj;
|
|
1689
|
+
try {
|
|
1690
|
+
originalUrlObj = new URL(_origin + ctx.originalUrl);
|
|
1691
|
+
} catch {
|
|
1692
|
+
sendBadRequest(ctx);
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1298
1695
|
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1696
|
+
// Build the clean target in DECODED space: the extension may be percent-encoded
|
|
1697
|
+
// in the raw URL (e.g. "%2Eejs"), so decoding first makes it literal and
|
|
1698
|
+
// sliceable, then we re-encode per segment (#14, #20). The URL constructor
|
|
1699
|
+
// already validated the encoding, so decodeURIComponent cannot throw — but
|
|
1700
|
+
// guard for symmetry with the other malformed-client-input guards.
|
|
1701
|
+
let decodedPath;
|
|
1702
|
+
try {
|
|
1703
|
+
decodedPath = decodeURIComponent(originalUrlObj.pathname);
|
|
1704
|
+
} catch {
|
|
1705
|
+
sendBadRequest(ctx);
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
let cleanPath = decodedPath.slice(0, decodedPath.length - hideExt.length);
|
|
1710
|
+
|
|
1711
|
+
// Special case: /index.ejs → /, /sezione/index.ejs → /sezione/
|
|
1712
|
+
const baseName = path.basename(cleanPath);
|
|
1713
|
+
if (options.index && options.index.length > 0) {
|
|
1714
|
+
for (const pattern of options.index) {
|
|
1715
|
+
if (typeof pattern === 'string' && (baseName + hideExt) === pattern) {
|
|
1716
|
+
// Redirect to the directory (with trailing slash)
|
|
1717
|
+
cleanPath = cleanPath.slice(0, cleanPath.length - baseName.length);
|
|
1718
|
+
break;
|
|
1719
|
+
}
|
|
1310
1720
|
}
|
|
1311
1721
|
}
|
|
1312
|
-
}
|
|
1313
1722
|
|
|
1314
|
-
|
|
1315
|
-
|
|
1723
|
+
// Re-encode per path segment: round-trips spaces, "%", etc. back into a valid
|
|
1724
|
+
// URL. encodeURIComponent may over-encode sub-delims in exotic filenames, but
|
|
1725
|
+
// the result always resolves to the same path.
|
|
1726
|
+
let redirectPath = cleanPath.split('/').map(encodeURIComponent).join('/');
|
|
1727
|
+
|
|
1728
|
+
// Open-redirect guard LAST: a Location starting with "//" (or "/\") is a
|
|
1729
|
+
// protocol-relative URL that would navigate off-origin ("GET //evil.com/foo.ejs").
|
|
1730
|
+
// Placed after re-encoding because a decoded "%2F" can reintroduce a leading
|
|
1731
|
+
// "//"; collapse any run of leading slashes/backslashes to a single slash.
|
|
1732
|
+
if (redirectPath.length > 1 && (redirectPath.charCodeAt(1) === 0x2F || redirectPath.charCodeAt(1) === 0x5C)) {
|
|
1733
|
+
redirectPath = '/' + redirectPath.replace(/^[/\\]+/, '');
|
|
1734
|
+
}
|
|
1316
1735
|
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1736
|
+
// Preserve query string
|
|
1737
|
+
const redirectUrl = redirectPath + (originalUrlObj.search || '');
|
|
1738
|
+
|
|
1739
|
+
ctx.status = hideRedirect;
|
|
1740
|
+
ctx.redirect(redirectUrl);
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1321
1743
|
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1744
|
+
// Check if URL has no extension → try adding the configured extension
|
|
1745
|
+
// Skip if original URL had trailing slash (trailing slash = directory intent)
|
|
1746
|
+
const extOfRequested = path.extname(requestedPath);
|
|
1747
|
+
if (!extOfRequested && requestedPath !== '' && !requestedPath.endsWith('/') && !hadTrailingSlash) {
|
|
1748
|
+
const pathWithExt = fullPath + hideExt;
|
|
1327
1749
|
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1750
|
+
// Security check: ensure resolved path is still within rootDir (boundary-aware)
|
|
1751
|
+
if (_isWithinRoot(pathWithExt, normalizedRootDir)) {
|
|
1752
|
+
try {
|
|
1753
|
+
const statWithExt = await fs.promises.stat(pathWithExt);
|
|
1754
|
+
if (statWithExt.isFile()) {
|
|
1755
|
+
// File with extension exists, serve it
|
|
1756
|
+
toOpen = pathWithExt;
|
|
1757
|
+
}
|
|
1758
|
+
} catch {
|
|
1759
|
+
// File with extension doesn't exist, continue normal flow
|
|
1335
1760
|
}
|
|
1336
|
-
} catch {
|
|
1337
|
-
// File with extension doesn't exist, continue normal flow
|
|
1338
1761
|
}
|
|
1339
1762
|
}
|
|
1340
1763
|
}
|
|
1341
|
-
}
|
|
1342
1764
|
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1765
|
+
// Check if path exists
|
|
1766
|
+
let stat;
|
|
1767
|
+
try {
|
|
1768
|
+
stat = await fs.promises.stat(toOpen);
|
|
1769
|
+
} catch {
|
|
1770
|
+
// File/directory doesn't exist or can't be accessed
|
|
1771
|
+
sendNotFound(ctx);
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1352
1774
|
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1775
|
+
// Hidden check: block access to the requested file or directory itself
|
|
1776
|
+
if (requestedPath !== '') {
|
|
1777
|
+
const entryName = path.basename(toOpen);
|
|
1778
|
+
const entryRelPath = path.relative(normalizedRootDir, toOpen).split(path.sep).join('/');
|
|
1779
|
+
if (isHiddenEntry(entryName, entryRelPath, stat.isDirectory())) {
|
|
1780
|
+
sendNotFound(ctx);
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
// Symlink boundary check (V-1): in protected modes reject any requested file
|
|
1786
|
+
// or directory whose realpath escapes rootDir (or, in 'deny' mode, any symlink
|
|
1787
|
+
// resolved below rootDir). The `_symlinkMode !== 'follow'` guard short-circuits
|
|
1788
|
+
// before any await so the default 'follow' mode stays truly zero-overhead.
|
|
1789
|
+
if (_symlinkMode !== 'follow' && !(await symlinkAllowed(toOpen))) {
|
|
1358
1790
|
sendNotFound(ctx);
|
|
1359
1791
|
return;
|
|
1360
1792
|
}
|
|
1361
|
-
}
|
|
1362
1793
|
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1794
|
+
if (stat.isDirectory()) {
|
|
1795
|
+
// Handle directory
|
|
1796
|
+
if (options.dirListing.enabled) {
|
|
1797
|
+
// Canonical trailing-slash redirect (V4): a directory URL
|
|
1798
|
+
// without a trailing slash serves an index/listing whose
|
|
1799
|
+
// relative links would resolve against the parent. Redirect
|
|
1800
|
+
// /dir → /dir/ (301) BEFORE serving so the browser's base is
|
|
1801
|
+
// the directory itself.
|
|
1802
|
+
if (options.dirListing.trailingSlash && !_pathEndsWithSlash) {
|
|
1803
|
+
// Build the Location from the parsed originalUrl (same
|
|
1804
|
+
// defense as the hideExtension redirect): re-parsing forces
|
|
1805
|
+
// the target to be origin-relative — an absolute-form
|
|
1806
|
+
// request target (`GET http://evil/x`, legal in HTTP/1.1
|
|
1807
|
+
// and reachable under useOriginalUrl:false + rewriting)
|
|
1808
|
+
// would otherwise become an off-origin `Location` (open
|
|
1809
|
+
// redirect). .pathname keeps urlPrefix and percent-encoding.
|
|
1810
|
+
let originalUrlObj;
|
|
1811
|
+
try {
|
|
1812
|
+
originalUrlObj = new URL(_origin + ctx.originalUrl);
|
|
1813
|
+
} catch {
|
|
1814
|
+
sendBadRequest(ctx);
|
|
1374
1815
|
return;
|
|
1375
1816
|
}
|
|
1817
|
+
// Collapse a leading "//" / "/\" (protocol-relative) to a
|
|
1818
|
+
// single slash before appending the canonical trailing one.
|
|
1819
|
+
let redirectPath = originalUrlObj.pathname;
|
|
1820
|
+
if (redirectPath.length > 1 && (redirectPath.charCodeAt(1) === 0x2F || redirectPath.charCodeAt(1) === 0x5C)) {
|
|
1821
|
+
redirectPath = '/' + redirectPath.replace(/^[/\\]+/, '');
|
|
1822
|
+
}
|
|
1823
|
+
ctx.status = 301;
|
|
1824
|
+
ctx.redirect(redirectPath + '/' + originalUrlObj.search);
|
|
1825
|
+
return;
|
|
1376
1826
|
}
|
|
1377
|
-
}
|
|
1378
1827
|
|
|
1379
|
-
|
|
1380
|
-
|
|
1828
|
+
// Search for index file matching configured patterns
|
|
1829
|
+
if (options.index && options.index.length > 0) {
|
|
1830
|
+
const indexFile = await findIndexFile(toOpen, options.index);
|
|
1831
|
+
if (indexFile) {
|
|
1832
|
+
const indexRelPath = path.relative(normalizedRootDir, path.join(toOpen, indexFile.name)).split(path.sep).join('/');
|
|
1833
|
+
if (!isHiddenEntry(indexFile.name, indexRelPath, false)) {
|
|
1834
|
+
const indexPath = path.join(toOpen, indexFile.name);
|
|
1835
|
+
// Symlink boundary check (V-1): an index file may itself be a
|
|
1836
|
+
// symlink escaping rootDir — validate before serving it.
|
|
1837
|
+
// Guarded so the default 'follow' mode skips the await entirely.
|
|
1838
|
+
if (_symlinkMode !== 'follow' && !(await symlinkAllowed(indexPath))) {
|
|
1839
|
+
sendNotFound(ctx);
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
await loadFile(indexPath, indexFile.stat);
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
// No index file found, show directory listing
|
|
1849
|
+
ctx.body = await show_dir(toOpen, ctx);
|
|
1850
|
+
} else {
|
|
1851
|
+
// Directory listing disabled
|
|
1852
|
+
sendNotFound(ctx);
|
|
1853
|
+
}
|
|
1854
|
+
return;
|
|
1381
1855
|
} else {
|
|
1382
|
-
//
|
|
1383
|
-
|
|
1856
|
+
// Canonical trailing-slash 404 (V4): a trailing slash means
|
|
1857
|
+
// "directory", but this path resolved to a FILE — a file is only
|
|
1858
|
+
// reachable at its slash-less URL. Return 404 (indistinguishable
|
|
1859
|
+
// from not-found) rather than serving the file at a non-canonical
|
|
1860
|
+
// URL. Disabled by dirListing.trailingSlash: false (v3 behavior).
|
|
1861
|
+
if (options.dirListing.trailingSlash && _pathEndsWithSlash) {
|
|
1862
|
+
sendNotFound(ctx);
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
await loadFile(toOpen, stat);
|
|
1866
|
+
return;
|
|
1384
1867
|
}
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1868
|
+
} catch (err) {
|
|
1869
|
+
_logger.error('[koa-classic-server] Unexpected error while serving the request:', err);
|
|
1870
|
+
if (ctx.headerSent || ctx.res.writableEnded) {
|
|
1871
|
+
ctx.res.destroy(); // response already in flight — nothing sane left to send
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1874
|
+
// Scrub representation/caching headers a partially-built response may
|
|
1875
|
+
// have left behind: a stale Content-Encoding would corrupt the error
|
|
1876
|
+
// page, a public Cache-Control could get the 500 cached by proxies.
|
|
1877
|
+
for (const h of ['Content-Encoding', 'Content-Disposition', 'Content-Range', 'Vary', 'ETag', 'Last-Modified', 'Accept-Ranges']) {
|
|
1878
|
+
ctx.remove(h);
|
|
1879
|
+
}
|
|
1880
|
+
ctx.set('Cache-Control', 'no-store');
|
|
1881
|
+
ctx.set('Content-Type', 'text/html; charset=utf-8');
|
|
1882
|
+
setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
|
|
1883
|
+
ctx.status = 500;
|
|
1884
|
+
ctx.body = _INTERNAL_ERROR_HTML;
|
|
1389
1885
|
}
|
|
1390
1886
|
|
|
1391
1887
|
// Internal functions
|
|
@@ -1419,13 +1915,23 @@ module.exports = function koaClassicServer(
|
|
|
1419
1915
|
rawBuffer = cached.buffer;
|
|
1420
1916
|
} else {
|
|
1421
1917
|
try {
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1918
|
+
// Single-flight: concurrent misses on the same path share one
|
|
1919
|
+
// readFile + cache insert; only the leader (the closure below)
|
|
1920
|
+
// runs, waiters await the same Promise. The key includes the
|
|
1921
|
+
// stat'd mtime+size so a request that observed a DIFFERENT
|
|
1922
|
+
// version of the file starts its own job instead of adopting
|
|
1923
|
+
// bytes that don't match the validators it will emit.
|
|
1924
|
+
const inflightKey = `${toOpen}:${fileStat.mtime.getTime()}:${fileStat.size}`;
|
|
1925
|
+
rawBuffer = await singleFlight(_inflightRawReads, inflightKey, async () => {
|
|
1926
|
+
const buf = await fs.promises.readFile(toOpen);
|
|
1927
|
+
refreshOrInsert(_rawFileCache, toOpen, {
|
|
1928
|
+
buffer: buf,
|
|
1929
|
+
mtime: fileStat.mtime.getTime(),
|
|
1930
|
+
size: fileStat.size,
|
|
1931
|
+
insertedAt: Date.now(),
|
|
1932
|
+
}, cached, staleByAge);
|
|
1933
|
+
return buf;
|
|
1934
|
+
});
|
|
1429
1935
|
} catch {
|
|
1430
1936
|
rawBuffer = null; // Fall through to disk reads later
|
|
1431
1937
|
}
|
|
@@ -1442,6 +1948,15 @@ module.exports = function koaClassicServer(
|
|
|
1442
1948
|
// Advertise range support on all file responses (including 304)
|
|
1443
1949
|
ctx.set('Accept-Ranges', 'bytes');
|
|
1444
1950
|
|
|
1951
|
+
// Opt-in static security headers (V-4). Applies to all static file
|
|
1952
|
+
// responses (200 / 206 / 304). Placed after the template early-return,
|
|
1953
|
+
// so template-rendered output is unaffected (that is the operator's
|
|
1954
|
+
// responsibility inside their render function). Off by default — see
|
|
1955
|
+
// the design philosophy: hardening is opt-in, not a default.
|
|
1956
|
+
if (staticSecurityHeaders.nosniff) {
|
|
1957
|
+
ctx.set('X-Content-Type-Options', 'nosniff');
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1445
1960
|
// Cache-Control set early — applies to all responses (200, 206, 304)
|
|
1446
1961
|
if (options.browserCacheEnabled) {
|
|
1447
1962
|
ctx.set('Cache-Control', `public, max-age=${options.browserCacheMaxAge}, must-revalidate`);
|
|
@@ -1464,6 +1979,67 @@ module.exports = function koaClassicServer(
|
|
|
1464
1979
|
}
|
|
1465
1980
|
}
|
|
1466
1981
|
|
|
1982
|
+
// Determine MIME type and compression encoding for the full-file response
|
|
1983
|
+
const mimeType = mime.lookup(toOpen) || 'application/octet-stream';
|
|
1984
|
+
const filename = path.basename(toOpen);
|
|
1985
|
+
|
|
1986
|
+
// Resolve compression: enabled + compressible MIME + meets minFileSize + client supports it
|
|
1987
|
+
let encoding = null; // 'br' | 'gzip' | null
|
|
1988
|
+
let potentiallyCompressible = false; // response content-negotiates on Accept-Encoding
|
|
1989
|
+
if (compressionConfig.enabled && compressionConfig.encodings.length > 0) {
|
|
1990
|
+
const isCompressibleMime = compressionConfig.mimeTypes.has(mimeType);
|
|
1991
|
+
const meetsMinSize = compressionConfig.minFileSize === false
|
|
1992
|
+
|| fileStat.size >= compressionConfig.minFileSize;
|
|
1993
|
+
if (isCompressibleMime && meetsMinSize) {
|
|
1994
|
+
potentiallyCompressible = true; // even if this client gets identity
|
|
1995
|
+
encoding = getClientEncoding(ctx.get('Accept-Encoding'));
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
// fullEtag is encoding-specific to avoid false 304 hits across representations.
|
|
2000
|
+
// Proxies use Vary: Accept-Encoding to cache separate versions per encoding.
|
|
2001
|
+
const etagSuffix = encoding === 'br' ? '-br' : encoding === 'gzip' ? '-gz' : '';
|
|
2002
|
+
const fullEtag = `"${fileStat.mtime.getTime()}-${fileStat.size}${etagSuffix}"`;
|
|
2003
|
+
|
|
2004
|
+
// Vary: Accept-Encoding as soon as the resource is *potentially* compressible —
|
|
2005
|
+
// regardless of whether THIS client gets a compressed variant, and regardless
|
|
2006
|
+
// of browserCacheEnabled. RFC 9110 §15.4.5: the 304 below must carry the same
|
|
2007
|
+
// Vary the 200 would; and a shared proxy must not serve the identity variant to
|
|
2008
|
+
// a client that would have received the compressed one (#7).
|
|
2009
|
+
if (potentiallyCompressible) {
|
|
2010
|
+
ctx.set('Vary', 'Accept-Encoding');
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
// Preconditions are evaluated BEFORE the Range branch: RFC 9110 §13.2.2 gives the
|
|
2014
|
+
// validators precedence over Range (steps 3/4 before step 5), so a conditional
|
|
2015
|
+
// request that matches returns 304 (Not Modified), not 206 (Partial Content).
|
|
2016
|
+
// Comparison uses fullEtag (the encoding-specific representation a full GET would
|
|
2017
|
+
// return); a 206 below re-tags itself with baseEtag (the identity partial it serves).
|
|
2018
|
+
if (options.browserCacheEnabled) {
|
|
2019
|
+
ctx.set('ETag', fullEtag);
|
|
2020
|
+
ctx.set('Last-Modified', fileStat.mtime.toUTCString());
|
|
2021
|
+
|
|
2022
|
+
// If-None-Match: "*" | comma-list, weak comparison (RFC 9110 §13.1.2).
|
|
2023
|
+
if (ifNoneMatchSatisfied(ctx.get('If-None-Match'), fullEtag)) {
|
|
2024
|
+
ctx.status = 304;
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
// If-Modified-Since (date validation). The mtime is truncated to whole seconds
|
|
2029
|
+
// before comparing: Last-Modified is emitted via toUTCString() (second precision
|
|
2030
|
+
// — HTTP dates have no milliseconds), so a client echoing that header back would
|
|
2031
|
+
// otherwise never match a sub-second mtime (e.g. 22:13:20.500 <= 22:13:20.000).
|
|
2032
|
+
const clientModifiedSince = ctx.get('If-Modified-Since');
|
|
2033
|
+
if (clientModifiedSince) {
|
|
2034
|
+
const clientDate = new Date(clientModifiedSince);
|
|
2035
|
+
const mtimeSeconds = Math.floor(fileStat.mtime.getTime() / 1000) * 1000;
|
|
2036
|
+
if (mtimeSeconds <= clientDate.getTime()) {
|
|
2037
|
+
ctx.status = 304;
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
|
|
1467
2043
|
// Range request handling (HTTP 206 Partial Content — compression skipped for ranges)
|
|
1468
2044
|
const rangeHeader = ctx.get('Range');
|
|
1469
2045
|
if (rangeHeader) {
|
|
@@ -1483,10 +2059,11 @@ module.exports = function koaClassicServer(
|
|
|
1483
2059
|
if (!ifRange || ifRange === baseEtag) {
|
|
1484
2060
|
const { start, end } = parsed;
|
|
1485
2061
|
const rangeLength = end - start + 1;
|
|
1486
|
-
const mimeType = mime.lookup(toOpen) || 'application/octet-stream';
|
|
1487
|
-
const filename = path.basename(toOpen);
|
|
1488
2062
|
|
|
1489
2063
|
ctx.status = 206;
|
|
2064
|
+
// 206 returns identity bytes → tag with baseEtag, not the encoding-specific
|
|
2065
|
+
// fullEtag set above. Last-Modified is already set (browserCacheEnabled).
|
|
2066
|
+
if (options.browserCacheEnabled) ctx.set('ETag', baseEtag);
|
|
1490
2067
|
ctx.set('Content-Range', `bytes ${start}-${end}/${fileSize}`);
|
|
1491
2068
|
ctx.set('Content-Type', mimeType);
|
|
1492
2069
|
ctx.set('Content-Length', String(rangeLength));
|
|
@@ -1494,8 +2071,9 @@ module.exports = function koaClassicServer(
|
|
|
1494
2071
|
|
|
1495
2072
|
if (ctx.method !== 'HEAD') {
|
|
1496
2073
|
if (rawBuffer) {
|
|
1497
|
-
// Serve range slice from in-memory buffer — zero disk I/O
|
|
1498
|
-
|
|
2074
|
+
// Serve range slice from in-memory buffer — zero disk I/O.
|
|
2075
|
+
// subarray(): zero-copy view; Buffer.slice is deprecated (DEP0158).
|
|
2076
|
+
ctx.body = rawBuffer.subarray(start, end + 1);
|
|
1499
2077
|
} else {
|
|
1500
2078
|
const src = fs.createReadStream(toOpen, { start, end });
|
|
1501
2079
|
src.on('error', (err) => {
|
|
@@ -1520,59 +2098,23 @@ module.exports = function koaClassicServer(
|
|
|
1520
2098
|
// Invalid Range → fall through to full 200 response
|
|
1521
2099
|
}
|
|
1522
2100
|
|
|
1523
|
-
// Determine MIME type and compression encoding for the full-file response
|
|
1524
|
-
const mimeType = mime.lookup(toOpen) || 'application/octet-stream';
|
|
1525
|
-
const filename = path.basename(toOpen);
|
|
1526
|
-
|
|
1527
|
-
// Resolve compression: enabled + compressible MIME + meets minFileSize + client supports it
|
|
1528
|
-
let encoding = null; // 'br' | 'gzip' | null
|
|
1529
|
-
if (compressionConfig.enabled && compressionConfig.encodings.length > 0) {
|
|
1530
|
-
const isCompressibleMime = compressionConfig.mimeTypes.has(mimeType);
|
|
1531
|
-
const meetsMinSize = compressionConfig.minFileSize === false
|
|
1532
|
-
|| fileStat.size >= compressionConfig.minFileSize;
|
|
1533
|
-
if (isCompressibleMime && meetsMinSize) {
|
|
1534
|
-
encoding = getClientEncoding(ctx.get('Accept-Encoding'));
|
|
1535
|
-
}
|
|
1536
|
-
}
|
|
1537
|
-
|
|
1538
|
-
// fullEtag is encoding-specific to avoid false 304 hits across representations.
|
|
1539
|
-
// Proxies use Vary: Accept-Encoding to cache separate versions per encoding.
|
|
1540
|
-
const etagSuffix = encoding === 'br' ? '-br' : encoding === 'gzip' ? '-gz' : '';
|
|
1541
|
-
const fullEtag = `"${fileStat.mtime.getTime()}-${fileStat.size}${etagSuffix}"`;
|
|
1542
|
-
|
|
1543
|
-
// ETag, Last-Modified, and 304 check — deferred until encoding is known
|
|
1544
|
-
if (options.browserCacheEnabled) {
|
|
1545
|
-
ctx.set('ETag', fullEtag);
|
|
1546
|
-
ctx.set('Last-Modified', fileStat.mtime.toUTCString());
|
|
1547
|
-
|
|
1548
|
-
// Check If-None-Match (ETag validation)
|
|
1549
|
-
const clientEtag = ctx.get('If-None-Match');
|
|
1550
|
-
if (clientEtag && clientEtag === fullEtag) {
|
|
1551
|
-
ctx.status = 304;
|
|
1552
|
-
return;
|
|
1553
|
-
}
|
|
1554
|
-
|
|
1555
|
-
// Check If-Modified-Since (date validation)
|
|
1556
|
-
const clientModifiedSince = ctx.get('If-Modified-Since');
|
|
1557
|
-
if (clientModifiedSince) {
|
|
1558
|
-
const clientDate = new Date(clientModifiedSince);
|
|
1559
|
-
if (fileStat.mtime.getTime() <= clientDate.getTime()) {
|
|
1560
|
-
ctx.status = 304;
|
|
1561
|
-
return;
|
|
1562
|
-
}
|
|
1563
|
-
}
|
|
1564
|
-
}
|
|
1565
|
-
|
|
1566
2101
|
// Common response headers
|
|
1567
2102
|
ctx.set('Content-Type', mimeType);
|
|
1568
2103
|
ctx.set('Content-Disposition', buildContentDisposition(filename));
|
|
1569
2104
|
|
|
1570
2105
|
if (encoding) {
|
|
1571
2106
|
// ── Compressed response ───────────────────────────────────────────────
|
|
2107
|
+
// Vary: Accept-Encoding is already set above (potentiallyCompressible).
|
|
1572
2108
|
ctx.set('Content-Encoding', encoding);
|
|
1573
|
-
ctx.set('Vary', 'Accept-Encoding'); // Required so proxies cache per-encoding
|
|
1574
2109
|
|
|
1575
|
-
|
|
2110
|
+
// Safety net (#4): the buffered path reads the WHOLE file into RAM and
|
|
2111
|
+
// compresses at max quality — fine for web assets, catastrophic for a
|
|
2112
|
+
// multi-GB log/CSV. Above compression.maxFileSize the bounded-RAM
|
|
2113
|
+
// streaming mode below is used instead (still compressed, not cached).
|
|
2114
|
+
const withinCompressCap = compressionConfig.maxFileSize === false
|
|
2115
|
+
|| fileStat.size <= compressionConfig.maxFileSize;
|
|
2116
|
+
|
|
2117
|
+
if (serverCacheConfig.compressedFile.enabled && withinCompressCap) {
|
|
1576
2118
|
// compressedFile cache mode: compress once → buffer in RAM → Content-Length known
|
|
1577
2119
|
const cacheKey = `${toOpen}:${encoding}`;
|
|
1578
2120
|
const cached = _compressedFileCache.peek(cacheKey);
|
|
@@ -1589,21 +2131,34 @@ module.exports = function koaClassicServer(
|
|
|
1589
2131
|
buf = cached.buffer; // Serve from cache
|
|
1590
2132
|
} else {
|
|
1591
2133
|
try {
|
|
1592
|
-
//
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
2134
|
+
// Single-flight: concurrent misses on the same path+encoding
|
|
2135
|
+
// share one read+compress+insert instead of N parallel brotli
|
|
2136
|
+
// jobs for identical content. A rejection is shared too: all
|
|
2137
|
+
// waiters land in this catch and use the uncompressed fallback.
|
|
2138
|
+
// The key includes the stat'd mtime+size so a request that
|
|
2139
|
+
// observed a DIFFERENT version of the file starts its own job
|
|
2140
|
+
// (its ETag/Last-Modified must describe the bytes it serves).
|
|
2141
|
+
const inflightKey = `${cacheKey}:${fileStat.mtime.getTime()}:${fileStat.size}`;
|
|
2142
|
+
buf = await singleFlight(_inflightCompressions, inflightKey, async () => {
|
|
2143
|
+
// Use rawFile buffer if available — avoids redundant disk read
|
|
2144
|
+
const rawData = rawBuffer || await fs.promises.readFile(toOpen);
|
|
2145
|
+
const compressed = await compressBuffer(rawData, encoding);
|
|
2146
|
+
refreshOrInsert(_compressedFileCache, cacheKey, {
|
|
2147
|
+
buffer: compressed,
|
|
2148
|
+
mtime: fileStat.mtime.getTime(),
|
|
2149
|
+
size: fileStat.size,
|
|
2150
|
+
insertedAt: Date.now(),
|
|
2151
|
+
}, cached, staleByAge);
|
|
2152
|
+
return compressed;
|
|
2153
|
+
});
|
|
1602
2154
|
} catch (err) {
|
|
1603
2155
|
_logger.error('Compression error:', err);
|
|
1604
|
-
// Fall back to uncompressed on any compression failure
|
|
2156
|
+
// Fall back to uncompressed on any compression failure. Vary STAYS
|
|
2157
|
+
// (the resource is still compressible), but the ETag must be reset to
|
|
2158
|
+
// the un-suffixed form: this identity body must not be cached by a
|
|
2159
|
+
// shared proxy under the -br/-gz validator (#7).
|
|
1605
2160
|
ctx.remove('Content-Encoding');
|
|
1606
|
-
ctx.
|
|
2161
|
+
if (options.browserCacheEnabled) ctx.set('ETag', baseEtag);
|
|
1607
2162
|
if (rawBuffer) {
|
|
1608
2163
|
ctx.set('Content-Length', String(rawBuffer.length));
|
|
1609
2164
|
if (ctx.method !== 'HEAD') {
|
|
@@ -1645,20 +2200,28 @@ module.exports = function koaClassicServer(
|
|
|
1645
2200
|
const compress = encoding === 'br'
|
|
1646
2201
|
? zlib.createBrotliCompress({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } })
|
|
1647
2202
|
: zlib.createGzip({ level: 6 });
|
|
1648
|
-
|
|
1649
|
-
//
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
2203
|
+
const src = rawBuffer
|
|
2204
|
+
? Readable.from(rawBuffer) // compress from in-memory buffer — no disk I/O
|
|
2205
|
+
: fs.createReadStream(toOpen);
|
|
2206
|
+
// pipeline (NOT pipe): teardown propagates in BOTH directions.
|
|
2207
|
+
// When the client disconnects mid-transfer, Koa destroys the body
|
|
2208
|
+
// (`compress`) and pipeline destroys `src` too, closing its file
|
|
2209
|
+
// descriptor. A bare src.pipe(compress) leaves the ReadStream
|
|
2210
|
+
// paused with the fd open forever — fd leak under aborted
|
|
2211
|
+
// downloads. Client disconnects are a normal event and are not
|
|
2212
|
+
// logged (avoids client-driven log spam).
|
|
2213
|
+
ctx.body = pipeline(src, compress, (err) => {
|
|
2214
|
+
if (!err || err.code === 'ERR_STREAM_PREMATURE_CLOSE') return;
|
|
2215
|
+
_logger.error('Stream error:', err);
|
|
2216
|
+
if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
|
|
2217
|
+
});
|
|
2218
|
+
} else {
|
|
2219
|
+
// HEAD: mirror the GET status and headers (RFC 9110 §9.3.2) — no
|
|
2220
|
+
// Content-Length, since the compressed size is unknown without
|
|
2221
|
+
// running the compression. The explicit status is required: with
|
|
2222
|
+
// no body ever assigned, Koa would otherwise respond its default 404.
|
|
2223
|
+
ctx.status = 200;
|
|
1660
2224
|
}
|
|
1661
|
-
// HEAD + streaming: no Content-Length available; Koa sends headers only via res.end()
|
|
1662
2225
|
}
|
|
1663
2226
|
|
|
1664
2227
|
} else {
|
|
@@ -1777,9 +2340,12 @@ module.exports = function koaClassicServer(
|
|
|
1777
2340
|
parts.push("</thead>");
|
|
1778
2341
|
parts.push("<tbody>");
|
|
1779
2342
|
|
|
1780
|
-
// Parent directory link
|
|
2343
|
+
// Parent directory link — omitted at the middleware's LOGICAL root
|
|
2344
|
+
// (pageHrefOutPrefix.pathname === '/'), not only at the absolute root: with
|
|
2345
|
+
// urlPrefix '/static', the listing of /static/ must not link to '/', which is
|
|
2346
|
+
// outside the served tree (#13).
|
|
1781
2347
|
const currentPath = pageHref.origin + pageHref.pathname;
|
|
1782
|
-
if (
|
|
2348
|
+
if (pageHrefOutPrefix.pathname !== "/") {
|
|
1783
2349
|
// Build parent directory URL without query parameters
|
|
1784
2350
|
const a_pD = currentPath.split("/");
|
|
1785
2351
|
a_pD.pop();
|
|
@@ -1788,8 +2354,9 @@ module.exports = function koaClassicServer(
|
|
|
1788
2354
|
parts.push(`<tr><td><a href="${escapeHtml(parentDirectory)}"><b>.. Parent Directory</b></a></td><td>DIR</td><td>-</td></tr>`);
|
|
1789
2355
|
}
|
|
1790
2356
|
|
|
2357
|
+
const emptyFolderRow = `<tr><td>empty folder</td><td></td><td></td></tr>`;
|
|
1791
2358
|
if (dir.length === 0) {
|
|
1792
|
-
parts.push(
|
|
2359
|
+
parts.push(emptyFolderRow);
|
|
1793
2360
|
} else {
|
|
1794
2361
|
const _listingBaseUrl = pageHref.origin + pageHref.pathname;
|
|
1795
2362
|
const _listingOriginPrefix = pageHref.origin + options.urlPrefix;
|
|
@@ -1838,10 +2405,18 @@ module.exports = function koaClassicServer(
|
|
|
1838
2405
|
const itemRelPath = dirRelPath ? dirRelPath + '/' + s_name : s_name;
|
|
1839
2406
|
if (isHiddenEntry(s_name, itemRelPath, itemIsDir)) return null;
|
|
1840
2407
|
|
|
2408
|
+
// Symlink boundary (V-1): in protected modes, flag entries whose
|
|
2409
|
+
// target escapes rootDir (or, in 'deny' mode, any symlink). Blocked
|
|
2410
|
+
// entries are rendered non-clickable and do not expose the target size.
|
|
2411
|
+
let isBlockedSymlink = false;
|
|
2412
|
+
if (_symlinkMode !== 'follow' && !isBrokenSymlink && (type === 3 || type === 0)) {
|
|
2413
|
+
if (!(await symlinkAllowed(itemPath))) isBlockedSymlink = true;
|
|
2414
|
+
}
|
|
2415
|
+
|
|
1841
2416
|
// Get file size — reuse cachedStat if already available (avoids double stat for symlinks)
|
|
1842
2417
|
let sizeStr = '-';
|
|
1843
2418
|
let sizeBytes = 0;
|
|
1844
|
-
if (!isBrokenSymlink) {
|
|
2419
|
+
if (!isBrokenSymlink && !isBlockedSymlink) {
|
|
1845
2420
|
try {
|
|
1846
2421
|
const itemStat = cachedStat || await fs.promises.stat(itemPath);
|
|
1847
2422
|
if (effectiveType === 1) {
|
|
@@ -1862,6 +2437,7 @@ module.exports = function koaClassicServer(
|
|
|
1862
2437
|
effectiveType,
|
|
1863
2438
|
isSymlink: type === 3,
|
|
1864
2439
|
isBrokenSymlink,
|
|
2440
|
+
isBlockedSymlink,
|
|
1865
2441
|
mimeType,
|
|
1866
2442
|
sizeStr,
|
|
1867
2443
|
sizeBytes,
|
|
@@ -1874,6 +2450,13 @@ module.exports = function koaClassicServer(
|
|
|
1874
2450
|
}
|
|
1875
2451
|
const items = rawItems.filter(Boolean);
|
|
1876
2452
|
|
|
2453
|
+
// Every entry was filtered out as hidden (dotfiles / alwaysHide / blacklist):
|
|
2454
|
+
// show the same "empty folder" row as a physically empty directory (#16), not a
|
|
2455
|
+
// header-only empty table (which would also hint that hidden files exist).
|
|
2456
|
+
if (items.length === 0) {
|
|
2457
|
+
parts.push(emptyFolderRow);
|
|
2458
|
+
}
|
|
2459
|
+
|
|
1877
2460
|
// Places directories before non-directories; falls back to `tieBreaker`
|
|
1878
2461
|
// when both items are in the same bucket. `effectiveType === 2` covers plain
|
|
1879
2462
|
// dirs and dir-resolved symlinks, matching the rest of the listing logic.
|
|
@@ -1923,14 +2506,16 @@ module.exports = function koaClassicServer(
|
|
|
1923
2506
|
// Symlink indicator label
|
|
1924
2507
|
const symlinkLabel = item.isBrokenSymlink
|
|
1925
2508
|
? ' ( Broken Symlink )'
|
|
1926
|
-
: item.
|
|
1927
|
-
? ' ( Symlink )'
|
|
1928
|
-
:
|
|
2509
|
+
: item.isBlockedSymlink
|
|
2510
|
+
? ' ( Blocked Symlink )'
|
|
2511
|
+
: item.isSymlink
|
|
2512
|
+
? ' ( Symlink )'
|
|
2513
|
+
: '';
|
|
1929
2514
|
|
|
1930
2515
|
if (item.isReserved) {
|
|
1931
2516
|
parts.push(`${rowStart} ${escapeHtml(item.name)}${symlinkLabel}</td> <td> DIR BUT RESERVED</td><td>${item.sizeStr}</td></tr>`);
|
|
1932
|
-
} else if (item.isBrokenSymlink) {
|
|
1933
|
-
// Broken symlink: name visible but not clickable
|
|
2517
|
+
} else if (item.isBrokenSymlink || item.isBlockedSymlink) {
|
|
2518
|
+
// Broken or policy-blocked symlink: name visible but not clickable
|
|
1934
2519
|
parts.push(`${rowStart} ${escapeHtml(item.name)}${symlinkLabel}</td> <td> ${escapeHtml(item.mimeType)} </td><td>${item.sizeStr}</td></tr>`);
|
|
1935
2520
|
} else {
|
|
1936
2521
|
parts.push(`${rowStart} <a href="${escapeHtml(item.itemUri)}">${escapeHtml(item.name)}</a>${symlinkLabel} </td> <td> ${escapeHtml(item.mimeType)} </td><td>${item.sizeStr}</td></tr>`);
|