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.
Files changed (106) hide show
  1. package/README.md +186 -818
  2. package/index.cjs +837 -252
  3. package/package.json +12 -6
  4. package/.github/workflows/npm-publish.yml +0 -98
  5. package/CLAUDE.md +0 -101
  6. package/__tests__/benchmark-results-baseline-v1.2.0.txt +0 -354
  7. package/__tests__/benchmark-results-optimized-v2.0.0.txt +0 -354
  8. package/__tests__/benchmark-results-v3.0.0.txt +0 -372
  9. package/__tests__/benchmark.js +0 -239
  10. package/__tests__/caching-headers.test.js +0 -556
  11. package/__tests__/compression-fixtures/data.json +0 -1
  12. package/__tests__/compression-fixtures/large.txt +0 -1
  13. package/__tests__/compression-fixtures/small.txt +0 -1
  14. package/__tests__/compression.test.js +0 -284
  15. package/__tests__/customTest/README.md +0 -6
  16. package/__tests__/customTest/loadConfig.util.js +0 -41
  17. package/__tests__/customTest/serversToLoad.util.js +0 -93
  18. package/__tests__/demo-regex-index.js +0 -140
  19. package/__tests__/deprecation-warnings.test.js +0 -105
  20. package/__tests__/directory-sorting-links.test.js +0 -135
  21. package/__tests__/dt-unknown.test.js +0 -635
  22. package/__tests__/ejs.test.js +0 -299
  23. package/__tests__/head-method.test.js +0 -160
  24. package/__tests__/hidden-fixtures/.dot-dir/inside.txt +0 -1
  25. package/__tests__/hidden-fixtures/.env +0 -2
  26. package/__tests__/hidden-fixtures/.well-known/acme-challenge.txt +0 -1
  27. package/__tests__/hidden-fixtures/config/secrets/password.txt +0 -1
  28. package/__tests__/hidden-fixtures/data.key +0 -1
  29. package/__tests__/hidden-fixtures/file.secret +0 -1
  30. package/__tests__/hidden-fixtures/index.html +0 -1
  31. package/__tests__/hidden-fixtures/normal.txt +0 -1
  32. package/__tests__/hidden-fixtures/subdir/.env +0 -1
  33. package/__tests__/hidden-fixtures/subdir/regular.txt +0 -1
  34. package/__tests__/hidden-option.test.js +0 -407
  35. package/__tests__/hideExtension.test.js +0 -607
  36. package/__tests__/index-option.test.js +0 -449
  37. package/__tests__/index.test.js +0 -345
  38. package/__tests__/listing.test.js +0 -437
  39. package/__tests__/logger.test.js +0 -232
  40. package/__tests__/performance.test.js +0 -370
  41. package/__tests__/publicWwwTest/cartella/sottocartella/ciao.html +0 -11
  42. package/__tests__/publicWwwTest/cartella/sottocartella/provaEjs/testEjs.ejs +0 -11
  43. package/__tests__/publicWwwTest/cartella vuota con spazi nel nome/file con spazio nel nome .txt +0 -1
  44. package/__tests__/publicWwwTest/cartella vuota con spazi nel nome /file con spazio nel nome .txt +0 -1
  45. package/__tests__/publicWwwTest/ejs-templates/complex.ejs +0 -56
  46. package/__tests__/publicWwwTest/ejs-templates/index.ejs +0 -30
  47. package/__tests__/publicWwwTest/ejs-templates/simple.ejs +0 -13
  48. package/__tests__/publicWwwTest/ejs-templates/with-conditional.ejs +0 -28
  49. package/__tests__/publicWwwTest/ejs-templates/with-escaping.ejs +0 -26
  50. package/__tests__/publicWwwTest/ejs-templates/with-loop.ejs +0 -16
  51. package/__tests__/publicWwwTest/hideext-test/about/index.html +0 -1
  52. package/__tests__/publicWwwTest/hideext-test/about.EJS +0 -1
  53. package/__tests__/publicWwwTest/hideext-test/about.ejs +0 -2
  54. package/__tests__/publicWwwTest/hideext-test/blog/articolo.ejs +0 -2
  55. package/__tests__/publicWwwTest/hideext-test/conflict-test/pagina +0 -1
  56. package/__tests__/publicWwwTest/hideext-test/conflict-test/pagina.ejs +0 -1
  57. package/__tests__/publicWwwTest/hideext-test/file.ejs.bak +0 -1
  58. package/__tests__/publicWwwTest/hideext-test/index.ejs +0 -2
  59. package/__tests__/publicWwwTest/hideext-test/photo.txt +0 -1
  60. package/__tests__/publicWwwTest/hideext-test/sezione/index.ejs +0 -1
  61. package/__tests__/publicWwwTest/hideext-test/style.css +0 -1
  62. package/__tests__/publicWwwTest/ile_vuoto.txt +0 -0
  63. package/__tests__/publicWwwTest/index.html +0 -11
  64. package/__tests__/publicWwwTest/prova file .txt +0 -2
  65. package/__tests__/publicWwwTest/semplicetxt.txt +0 -1
  66. package/__tests__/publicWwwTest/test-page.html +0 -1
  67. package/__tests__/publicWwwTest/test.txt +0 -1
  68. package/__tests__/range-fixtures/sample.txt +0 -1
  69. package/__tests__/range.test.js +0 -223
  70. package/__tests__/security-headers.test.js +0 -165
  71. package/__tests__/security.test.js +0 -322
  72. package/__tests__/server-cache-fixtures/large.txt +0 -1
  73. package/__tests__/server-cache-fixtures/small.txt +0 -1
  74. package/__tests__/server-cache.test.js +0 -594
  75. package/__tests__/setup-benchmark.js +0 -178
  76. package/__tests__/symlink.test.js +0 -441
  77. package/__tests__/template-timeout.test.js +0 -321
  78. package/__tests__/test-regex-quick.js +0 -158
  79. package/__tests__/useOriginalUrl.test.js +0 -213
  80. package/docs/ACTION_PLAN.md +0 -293
  81. package/docs/BENCHMARKS.md +0 -317
  82. package/docs/CHANGELOG.md +0 -906
  83. package/docs/CODE_REVIEW.md +0 -300
  84. package/docs/DEBUG_REPORT.md +0 -593
  85. package/docs/DOCUMENTATION.md +0 -1864
  86. package/docs/EXAMPLES_INDEX_OPTION.md +0 -395
  87. package/docs/FLOW_DIAGRAM.md +0 -954
  88. package/docs/INDEX_OPTION_PRIORITY.md +0 -527
  89. package/docs/OPTIMIZATION_HTTP_CACHING.md +0 -689
  90. package/docs/OPTIMIZATION_ROADMAP_for_V3.md +0 -864
  91. package/docs/PERFORMANCE_ANALYSIS.md +0 -839
  92. package/docs/PERFORMANCE_COMPARISON.md +0 -388
  93. package/docs/noteExports.md +0 -148
  94. package/docs/security_improvement_for_V3.md +0 -421
  95. package/docs/template-engine/TEMPLATE_ENGINE_GUIDE.md +0 -1734
  96. package/docs/template-engine/esempi-incrementali.js +0 -192
  97. package/docs/template-engine/examples/esempio1-nessun-dato.ejs +0 -12
  98. package/docs/template-engine/examples/esempio2-una-variabile.ejs +0 -11
  99. package/docs/template-engine/examples/esempio3-piu-variabili.ejs +0 -15
  100. package/docs/template-engine/examples/esempio4-condizionale.ejs +0 -18
  101. package/docs/template-engine/examples/esempio5-loop.ejs +0 -18
  102. package/docs/template-engine/examples/index-esempi.html +0 -181
  103. package/docs/template-engine/examples/index.html +0 -40
  104. package/docs/template-engine/examples/test.ejs +0 -64
  105. package/eslint.config.mjs +0 -17
  106. 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
- if (this.warnInterval !== false) {
495
- const now = Date.now();
496
- if (now - this._lastWarnAt >= this.warnInterval) {
497
- this.logger.warn(`[koa-classic-server] serverCache.${this.cacheLabel}: maxSize reached, evicting LFU entries. Consider increasing maxSize.`);
498
- this._lastWarnAt = now;
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: 100000, // Soft cap on entries shown / sorted / stat'd per listing.
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: ["index.html"], // Index file name(s) - must be an ARRAY:
547
- // - Array of strings: ["index.html", "index.htm", "default.html"]
548
- // - Array of RegExp: [/index\.html/i, /default\.(html|htm)/i]
549
- // - Mixed array: ["index.html", /index\.[eE][jJ][sS]/]
550
- // Priority is determined by array order (first match wins)
551
- urlPrefix: "", // URL path prefix
552
- urlsReserved: [], // Reserved paths (first level only)
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
- const options = opts || {};
634
- options.template = opts.template || {};
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
- options.urlPrefix = typeof options.urlPrefix === 'string' ? options.urlPrefix : "";
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
- options.urlsReserved = Array.isArray(options.urlsReserved) ? options.urlsReserved : [];
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
- options.browserCacheMaxAge = typeof options.browserCacheMaxAge === 'number' && options.browserCacheMaxAge >= 0 ? options.browserCacheMaxAge : 3600;
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
- // Returns the client's preferred encoding based on Accept-Encoding header,
1091
- // filtered against the enabled encodings list. Returns null if no match.
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
- if (acceptEncoding.includes(enc)) return enc;
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
- if (fullUrl.charAt(fullUrl.length - 1) === '/') {
1193
- pageHref = new URL(fullUrl.slice(0, -1));
1194
- } else {
1195
- pageHref = new URL(fullUrl);
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
- pageHrefOutPrefix = new URL(hrefOutPrefix);
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
- // Path traversal protection: build and validate safe file path
1229
- let requestedPath = "";
1230
- if (pageHrefOutPrefix.pathname === "/") {
1231
- requestedPath = "";
1232
- } else {
1233
- requestedPath = decodeURIComponent(pageHrefOutPrefix.pathname);
1234
- }
1235
-
1236
- // Null byte guard: path.normalize() throws ERR_INVALID_ARG_VALUE for paths
1237
- // containing \0. Reject early with 400 Bad Request before it reaches fs calls.
1238
- if (requestedPath.includes('\0')) {
1239
- ctx.status = 400;
1240
- ctx.body = 'Bad Request';
1241
- return;
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
- const normalizedPath = path.normalize(requestedPath);
1245
- const fullPath = path.join(normalizedRootDir, normalizedPath);
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
- // Security check: ensure resolved path is within rootDir.
1248
- // Covers: ../ traversal, URL-encoded variants (%2e%2e%2f), and on Windows
1249
- // backslash sequences (path.normalize converts \ to / before the check).
1250
- if (!fullPath.startsWith(normalizedRootDir)) {
1251
- ctx.status = 403;
1252
- ctx.body = 'Forbidden';
1253
- return;
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
- // Hidden check: block requests that traverse a hidden directory.
1257
- // Stops at length-1 because the leaf (the file or dir being served) is
1258
- // checked separately by the file/listing path with its real stat.isDirectory().
1259
- if (requestedPath !== '') {
1260
- const segments = normalizedPath.split(path.sep).filter(Boolean);
1261
- for (let i = 0; i < segments.length - 1; i++) {
1262
- const segName = segments[i];
1263
- const segRelPath = segments.slice(0, i + 1).join('/');
1264
- if (isHiddenEntry(segName, segRelPath, true)) {
1265
- sendNotFound(ctx);
1266
- return;
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
- let toOpen = fullPath;
1272
-
1273
- // hideExtension logic: redirect URLs with extension and resolve clean URLs
1274
- if (options.hideExtension) {
1275
- const hideExt = options.hideExtension.ext;
1276
- const hideRedirect = options.hideExtension.redirect;
1277
-
1278
- // Trailing slash check via string — avoids a full new URL() construction
1279
- const rawPath = urlToUse.split('?')[0];
1280
- const hadTrailingSlash = rawPath.length > 1 && rawPath.endsWith('/');
1281
-
1282
- // Strip a trailing slash before the extension check so URLs like /foo.html/
1283
- // still match (the slash is URL formality, not part of the filename) — without
1284
- // this, /foo.html/ would skip the redirect and 404 trying to open it as a dir.
1285
- const pathForExtCheck = hadTrailingSlash ? rawPath.slice(0, -1) : requestedPath;
1286
- if (pathForExtCheck.endsWith(hideExt)) {
1287
- // Build redirect target using ctx.originalUrl (always, regardless of useOriginalUrl)
1288
- const originalUrlObj = new URL(_origin + ctx.originalUrl);
1289
- let redirectPath = originalUrlObj.pathname;
1290
-
1291
- // Collapse leading slashes: a Location header starting with "//" (or "/\")
1292
- // is a protocol-relative URL and would let "GET //evil.com/foo.ejs" redirect
1293
- // off-origin. path.normalize() upstream already collapses these for the
1294
- // filesystem check, so the source-of-truth URL has a single leading slash.
1295
- if (redirectPath.length > 1 && (redirectPath.charCodeAt(1) === 0x2F || redirectPath.charCodeAt(1) === 0x5C)) {
1296
- redirectPath = '/' + redirectPath.replace(/^[/\\]+/, '');
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
- redirectPath = redirectPath.slice(0, redirectPath.length - hideExt.length);
1300
-
1301
- // Special case: /index.ejs /, /sezione/index.ejs /sezione/
1302
- const baseName = path.basename(redirectPath);
1303
- // Check if the remaining path points to an index file
1304
- if (options.index && options.index.length > 0) {
1305
- for (const pattern of options.index) {
1306
- if (typeof pattern === 'string' && (baseName + hideExt) === pattern) {
1307
- // Redirect to the directory (with trailing slash)
1308
- redirectPath = redirectPath.slice(0, redirectPath.length - baseName.length);
1309
- break;
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
- // Preserve query string
1315
- const redirectUrl = redirectPath + (originalUrlObj.search || '');
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
- ctx.status = hideRedirect;
1318
- ctx.redirect(redirectUrl);
1319
- return;
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
- // Check if URL has no extension → try adding the configured extension
1323
- // Skip if original URL had trailing slash (trailing slash = directory intent)
1324
- const extOfRequested = path.extname(requestedPath);
1325
- if (!extOfRequested && requestedPath !== '' && !requestedPath.endsWith('/') && !hadTrailingSlash) {
1326
- const pathWithExt = fullPath + hideExt;
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
- // Security check: ensure resolved path is still within rootDir
1329
- if (pathWithExt.startsWith(normalizedRootDir)) {
1330
- try {
1331
- const statWithExt = await fs.promises.stat(pathWithExt);
1332
- if (statWithExt.isFile()) {
1333
- // File with extension exists, serve it
1334
- toOpen = pathWithExt;
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
- // Check if path exists
1344
- let stat;
1345
- try {
1346
- stat = await fs.promises.stat(toOpen);
1347
- } catch {
1348
- // File/directory doesn't exist or can't be accessed
1349
- sendNotFound(ctx);
1350
- return;
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
- // Hidden check: block access to the requested file or directory itself
1354
- if (requestedPath !== '') {
1355
- const entryName = path.basename(toOpen);
1356
- const entryRelPath = path.relative(normalizedRootDir, toOpen).split(path.sep).join('/');
1357
- if (isHiddenEntry(entryName, entryRelPath, stat.isDirectory())) {
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
- if (stat.isDirectory()) {
1364
- // Handle directory
1365
- if (options.dirListing.enabled) {
1366
- // Search for index file matching configured patterns
1367
- if (options.index && options.index.length > 0) {
1368
- const indexFile = await findIndexFile(toOpen, options.index);
1369
- if (indexFile) {
1370
- const indexRelPath = path.relative(normalizedRootDir, path.join(toOpen, indexFile.name)).split(path.sep).join('/');
1371
- if (!isHiddenEntry(indexFile.name, indexRelPath, false)) {
1372
- const indexPath = path.join(toOpen, indexFile.name);
1373
- await loadFile(indexPath, indexFile.stat);
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
- // No index file found, show directory listing
1380
- ctx.body = await show_dir(toOpen, ctx);
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
- // Directory listing disabled
1383
- sendNotFound(ctx);
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
- return;
1386
- } else {
1387
- await loadFile(toOpen, stat);
1388
- return;
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
- rawBuffer = await fs.promises.readFile(toOpen);
1423
- refreshOrInsert(_rawFileCache, toOpen, {
1424
- buffer: rawBuffer,
1425
- mtime: fileStat.mtime.getTime(),
1426
- size: fileStat.size,
1427
- insertedAt: Date.now(),
1428
- }, cached, staleByAge);
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
- ctx.body = rawBuffer.slice(start, end + 1);
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
- if (serverCacheConfig.compressedFile.enabled) {
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
- // Use rawFile buffer if available avoids redundant disk read
1593
- const rawData = rawBuffer || await fs.promises.readFile(toOpen);
1594
- buf = await compressBuffer(rawData, encoding);
1595
-
1596
- refreshOrInsert(_compressedFileCache, cacheKey, {
1597
- buffer: buf,
1598
- mtime: fileStat.mtime.getTime(),
1599
- size: fileStat.size,
1600
- insertedAt: Date.now(),
1601
- }, cached, staleByAge);
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.remove('Vary');
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
- if (rawBuffer) {
1649
- // Compress from in-memory buffer — no disk I/O
1650
- const src = Readable.from(rawBuffer);
1651
- ctx.body = src.pipe(compress);
1652
- } else {
1653
- const src = fs.createReadStream(toOpen);
1654
- src.on('error', (err) => {
1655
- _logger.error('Stream error:', err);
1656
- if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
1657
- });
1658
- ctx.body = src.pipe(compress);
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 (currentPath !== pageHrefOutPrefix.origin + "/") {
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(`<tr><td>empty folder</td><td></td><td></td></tr>`);
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.isSymlink
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>`);