koa-classic-server 3.0.0 → 3.1.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 (105) hide show
  1. package/README.md +44 -120
  2. package/index.cjs +236 -21
  3. package/package.json +10 -5
  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__/hidden-fixtures/.dot-dir/inside.txt +0 -1
  24. package/__tests__/hidden-fixtures/.env +0 -2
  25. package/__tests__/hidden-fixtures/.well-known/acme-challenge.txt +0 -1
  26. package/__tests__/hidden-fixtures/config/secrets/password.txt +0 -1
  27. package/__tests__/hidden-fixtures/data.key +0 -1
  28. package/__tests__/hidden-fixtures/file.secret +0 -1
  29. package/__tests__/hidden-fixtures/index.html +0 -1
  30. package/__tests__/hidden-fixtures/normal.txt +0 -1
  31. package/__tests__/hidden-fixtures/subdir/.env +0 -1
  32. package/__tests__/hidden-fixtures/subdir/regular.txt +0 -1
  33. package/__tests__/hidden-option.test.js +0 -407
  34. package/__tests__/hideExtension.test.js +0 -607
  35. package/__tests__/index-option.test.js +0 -449
  36. package/__tests__/index.test.js +0 -345
  37. package/__tests__/listing.test.js +0 -437
  38. package/__tests__/logger.test.js +0 -232
  39. package/__tests__/performance.test.js +0 -370
  40. package/__tests__/publicWwwTest/cartella/sottocartella/ciao.html +0 -11
  41. package/__tests__/publicWwwTest/cartella/sottocartella/provaEjs/testEjs.ejs +0 -11
  42. package/__tests__/publicWwwTest/cartella vuota con spazi nel nome/file con spazio nel nome .txt +0 -1
  43. package/__tests__/publicWwwTest/cartella vuota con spazi nel nome /file con spazio nel nome .txt +0 -1
  44. package/__tests__/publicWwwTest/ejs-templates/complex.ejs +0 -56
  45. package/__tests__/publicWwwTest/ejs-templates/index.ejs +0 -30
  46. package/__tests__/publicWwwTest/ejs-templates/simple.ejs +0 -13
  47. package/__tests__/publicWwwTest/ejs-templates/with-conditional.ejs +0 -28
  48. package/__tests__/publicWwwTest/ejs-templates/with-escaping.ejs +0 -26
  49. package/__tests__/publicWwwTest/ejs-templates/with-loop.ejs +0 -16
  50. package/__tests__/publicWwwTest/hideext-test/about/index.html +0 -1
  51. package/__tests__/publicWwwTest/hideext-test/about.EJS +0 -1
  52. package/__tests__/publicWwwTest/hideext-test/about.ejs +0 -2
  53. package/__tests__/publicWwwTest/hideext-test/blog/articolo.ejs +0 -2
  54. package/__tests__/publicWwwTest/hideext-test/conflict-test/pagina +0 -1
  55. package/__tests__/publicWwwTest/hideext-test/conflict-test/pagina.ejs +0 -1
  56. package/__tests__/publicWwwTest/hideext-test/file.ejs.bak +0 -1
  57. package/__tests__/publicWwwTest/hideext-test/index.ejs +0 -2
  58. package/__tests__/publicWwwTest/hideext-test/photo.txt +0 -1
  59. package/__tests__/publicWwwTest/hideext-test/sezione/index.ejs +0 -1
  60. package/__tests__/publicWwwTest/hideext-test/style.css +0 -1
  61. package/__tests__/publicWwwTest/ile_vuoto.txt +0 -0
  62. package/__tests__/publicWwwTest/index.html +0 -11
  63. package/__tests__/publicWwwTest/prova file .txt +0 -2
  64. package/__tests__/publicWwwTest/semplicetxt.txt +0 -1
  65. package/__tests__/publicWwwTest/test-page.html +0 -1
  66. package/__tests__/publicWwwTest/test.txt +0 -1
  67. package/__tests__/range-fixtures/sample.txt +0 -1
  68. package/__tests__/range.test.js +0 -223
  69. package/__tests__/security-headers.test.js +0 -165
  70. package/__tests__/security.test.js +0 -322
  71. package/__tests__/server-cache-fixtures/large.txt +0 -1
  72. package/__tests__/server-cache-fixtures/small.txt +0 -1
  73. package/__tests__/server-cache.test.js +0 -594
  74. package/__tests__/setup-benchmark.js +0 -178
  75. package/__tests__/symlink.test.js +0 -441
  76. package/__tests__/template-timeout.test.js +0 -321
  77. package/__tests__/test-regex-quick.js +0 -158
  78. package/__tests__/useOriginalUrl.test.js +0 -213
  79. package/docs/ACTION_PLAN.md +0 -293
  80. package/docs/BENCHMARKS.md +0 -317
  81. package/docs/CHANGELOG.md +0 -880
  82. package/docs/CODE_REVIEW.md +0 -300
  83. package/docs/DEBUG_REPORT.md +0 -593
  84. package/docs/DOCUMENTATION.md +0 -1864
  85. package/docs/EXAMPLES_INDEX_OPTION.md +0 -395
  86. package/docs/FLOW_DIAGRAM.md +0 -954
  87. package/docs/INDEX_OPTION_PRIORITY.md +0 -527
  88. package/docs/OPTIMIZATION_HTTP_CACHING.md +0 -689
  89. package/docs/OPTIMIZATION_ROADMAP_for_V3.md +0 -864
  90. package/docs/PERFORMANCE_ANALYSIS.md +0 -839
  91. package/docs/PERFORMANCE_COMPARISON.md +0 -388
  92. package/docs/noteExports.md +0 -148
  93. package/docs/security_improvement_for_V3.md +0 -421
  94. package/docs/template-engine/TEMPLATE_ENGINE_GUIDE.md +0 -1734
  95. package/docs/template-engine/esempi-incrementali.js +0 -192
  96. package/docs/template-engine/examples/esempio1-nessun-dato.ejs +0 -12
  97. package/docs/template-engine/examples/esempio2-una-variabile.ejs +0 -11
  98. package/docs/template-engine/examples/esempio3-piu-variabili.ejs +0 -15
  99. package/docs/template-engine/examples/esempio4-condizionale.ejs +0 -18
  100. package/docs/template-engine/examples/esempio5-loop.ejs +0 -18
  101. package/docs/template-engine/examples/index-esempi.html +0 -181
  102. package/docs/template-engine/examples/index.html +0 -40
  103. package/docs/template-engine/examples/test.ejs +0 -64
  104. package/eslint.config.mjs +0 -17
  105. package/jest.config.js +0 -18
package/README.md CHANGED
@@ -17,7 +17,7 @@ The 3.0 series builds on 2.x with new observability hooks, bounded resource usag
17
17
 
18
18
  ✅ **Design philosophy made explicit** — *"if a file is in `rootDir`, `GET` returns it"* — codified in [`CLAUDE.md`](./CLAUDE.md), with a **Security Checklist** + **Suggested Production Security Configuration** in this README and `docs/DOCUMENTATION.md`
19
19
  ✅ **`dirListing` namespace** — listing options grouped under one structured object (`enabled`, `maxEntries`, `entriesPerPage`); the v2 `showDirContents` flag is kept as a deprecated alias with a one-time warning
20
- ✅ **Soft cap on listing rendering** — `dirListing.maxEntries` defaults to `100000` as a *safety net* against accidentally-huge directories (broken log rotation, mistakenly mounted FS), NOT as a policy restriction; banner + `X-Dir-Truncated` header on the rare hit. Opt-in RAM-bounded streaming reads planned for v3.1.
20
+ ✅ **Soft cap on listing rendering** — `dirListing.maxEntries` defaults to `10000` as a *safety net* against accidentally-huge directories (broken log rotation, mistakenly mounted FS), NOT as a policy restriction; banner + `X-Dir-Truncated` header on the rare hit. Opt-in RAM-bounded streaming reads planned for v3.1.
21
21
  ✅ **Paginated listings** — `dirListing.entriesPerPage` adds 0-based `?page=N` navigation with First/Prev/Next/Last + `X-Dir-Pagination` header
22
22
  ✅ **Template render timeout + AbortSignal** — `template.renderTimeout` (default 30s) + a per-request `template.signal` so slow renders never wedge the server
23
23
  ✅ **Injectable logger** — pass any `{ error, warn, info, debug }`-shaped logger (Pino, Bunyan, Winston, console) for full observability
@@ -224,7 +224,7 @@ app.use(koaClassicServer(__dirname + '/public', {
224
224
 
225
225
  ### 8. Hidden Files & Dot-File Protection (V3 default: hidden)
226
226
 
227
- Dot-files and dot-directories are **visible by default in v3** — aligned with the "file server first" philosophy (see [`CLAUDE.md`](./CLAUDE.md)). For production deployments where `.env`, `.git/config`, etc. could be served accidentally, **opt into hardening** explicitly via `hidden.dotFiles.default: 'hidden'`. This is the first item on the [Security Checklist](#design-philosophy--security-checklist).
227
+ Dot-files and dot-directories are **visible by default in v3** — aligned with the "file server first" philosophy (see [`CLAUDE.md`](./CLAUDE.md)). For production deployments where `.env`, `.git/config`, etc. could be served accidentally, **opt into hardening** explicitly via `hidden.dotFiles.default: 'hidden'`. See the [Security Hardening Guide Dot-files and dot-directories](./docs/SECURITY_HARDENING.md#31-dot-files-and-dot-directories-env-git-keys).
228
228
 
229
229
  ```javascript
230
230
  app.use(koaClassicServer(__dirname + '/www', {
@@ -305,10 +305,12 @@ const koaClassicServer = require('koa-classic-server');
305
305
 
306
306
  const app = new Koa();
307
307
 
308
- // Allowlist Host headers to mitigate DNS rebinding (see docs/DOCUMENTATION.md → Sicurezza).
308
+ // Allowlist Host headers to mitigate DNS rebinding (see docs/DOCUMENTATION.md → DNS Rebinding).
309
+ // Uses raw ctx.get('host') (not ctx.host) to avoid trusting a forgeable X-Forwarded-Host.
309
310
  const ALLOWED_HOSTS = new Set(['app.example.com', 'localhost:3000']);
311
+ const normalizeHost = (h) => (h || '').toLowerCase().replace(/\.$/, '');
310
312
  app.use(async (ctx, next) => {
311
- if (!ALLOWED_HOSTS.has(ctx.host)) { ctx.status = 421; ctx.body = 'Host not allowed'; return; }
313
+ if (!ALLOWED_HOSTS.has(normalizeHost(ctx.get('host')))) { ctx.status = 421; ctx.body = 'Host not allowed'; return; }
312
314
  await next();
313
315
  });
314
316
 
@@ -488,9 +490,30 @@ The middleware follows symbolic links transparently via `fs.promises.stat()` —
488
490
  | Symlink to file | `( Symlink )` | yes | target MIME |
489
491
  | Symlink to directory | `( Symlink )` | yes | `DIR` |
490
492
  | Broken symlink | `( Broken Symlink )` | no | original MIME guess |
493
+ | Policy-blocked symlink | `( Blocked Symlink )` | no | MIME guess, size hidden |
491
494
 
492
495
  Regular files incur zero additional `stat()` overhead.
493
496
 
497
+ #### `symlinks` policy (V3.1+) — protect against symlink escape
498
+
499
+ By default (`symlinks: 'follow'`) a symlink inside `rootDir` is followed **even when its target lives outside `rootDir`** — consistent with the *"file server first"* philosophy (`rootDir` is the source of truth). If `rootDir` contains directories writable by untrusted parties (uploads, spool, multi-tenant hosting), a planted symlink could then read any file the process can access. Opt into containment:
500
+
501
+ | Value | Behavior | Overhead |
502
+ |---|---|---|
503
+ | `'follow'` *(default)* | Follow symlinks anywhere, including outside `rootDir`. Historical behavior. | none |
504
+ | `'follow-within-root'` | Follow only while the resolved realpath stays inside `rootDir`; escaping links → **404**. | one `realpath()` per served path |
505
+ | `'deny'` | Never follow a symlink resolved **below** `rootDir`. | one `realpath()` per served path |
506
+
507
+ ```js
508
+ app.use(koaClassicServer(rootDir, { symlinks: 'follow-within-root' }));
509
+ ```
510
+
511
+ Notes:
512
+ - **`rootDir` may itself be a symlink** (atomic-deploy / Capistrano / Nix) in every mode: the boundary is pinned to `realpath(rootDir)` resolved once at factory init.
513
+ - Protected modes require `rootDir` to **exist at factory time** (they resolve its realpath up front) and throw otherwise.
514
+ - In the directory listing, blocked symlinks appear as `( Blocked Symlink )`, non-clickable, and do not expose the target's size.
515
+ - Residual risk: the check is realpath-based, so (a) a symlink swapped between the check and the file open (TOCTOU) is not fully prevented, and (b) **hardlinks** cannot be detected — a hardlink has no resolvable target path, so its `realpath` is inside `rootDir` even when it points to an external inode. For hostile multi-tenant setups combine with OS-level isolation (chroot, per-tenant mounts, `nosymfollow`, a dedicated upload filesystem).
516
+
494
517
  ---
495
518
 
496
519
  ## Security
@@ -500,12 +523,14 @@ Regular files incur zero additional `stat()` overhead.
500
523
  #### 1. Path Traversal
501
524
 
502
525
  ```text
503
- GET /../../etc/passwd → 403 Forbidden
504
- GET /%2e%2e%2fpackage.json → 403 Forbidden
526
+ GET /../../etc/passwd → 404 Not Found
527
+ GET /%2e%2e%2fpackage.json → 404 Not Found
505
528
  GET /file\0.txt → 400 Bad Request (null-byte guard)
529
+ GET /% → 400 Bad Request (malformed percent-encoding)
530
+ Host: bad host → 400 Bad Request (invalid Host header)
506
531
  ```
507
532
 
508
- Defense in depth: null-byte rejection → `path.normalize()` → resolved-path boundary check against `rootDir`.
533
+ Defense in depth: malformed-request rejection (bad percent-encoding / invalid `Host` → 400) → null-byte rejection → `path.normalize()` → resolved-path boundary check against `rootDir`. The boundary check is boundary-aware (`rootDir` exactly or `rootDir` + separator — never a sibling like `/srv/wwwsecret` for root `/srv/www`) and returns **404** so "outside root" is indistinguishable from "not found", matching symlink-escape and hidden entries. Malformed inputs return **400**, never an unhandled 500.
509
534
 
510
535
  #### 2. XSS in Directory Listing
511
536
 
@@ -527,11 +552,11 @@ The middleware emits the following on directory listings and error pages (404/40
527
552
  | `Referrer-Policy` | `no-referrer` |
528
553
  | `Permissions-Policy` | `camera=(), microphone=(), geolocation=(), payment=()` |
529
554
 
530
- > ⚠️ User-served static files (HTML/JS/CSS on disk) are returned **without** these headers — by design. See [docs/DOCUMENTATION.md Limiti dei Security Headers](./docs/DOCUMENTATION.md#limiti-dei-security-headers-sui-file-statici) for an upstream-middleware example that applies your own CSP/HSTS to static files.
555
+ > ⚠️ User-served static files (HTML/JS/CSS on disk) are returned **without** these headers — by design. Enable `staticSecurityHeaders: { nosniff: true }` and see the [Security Hardening Guide Security headers on static files](./docs/SECURITY_HARDENING.md#35-security-headers-on-static-files) for applying your own CSP/HSTS via an upstream middleware.
531
556
 
532
557
  #### 5. DNS Rebinding
533
558
 
534
- The middleware does not validate the `Host` header — that belongs to the reverse proxy or an application-level allowlist. See [docs/DOCUMENTATION.md → DNS Rebinding](./docs/DOCUMENTATION.md#dns-rebinding--valida-lheader-host-a-monte) for nginx + Koa allowlist examples.
559
+ The middleware does not validate the `Host` header — that belongs to the reverse proxy or an application-level allowlist. See the [Security Hardening Guide Host validation / DNS rebinding](./docs/SECURITY_HARDENING.md#36-host-validation--dns-rebinding) for nginx + Koa allowlist examples.
535
560
 
536
561
  #### 6. Reserved URLs
537
562
 
@@ -557,119 +582,18 @@ File metadata is verified before streaming. A file deleted between check and acc
557
582
  - [Security improvement roadmap →](./docs/security_improvement_for_V3.md)
558
583
  - [Security tests →](./__tests__/security.test.js)
559
584
 
560
- ### Design philosophy & Security Checklist
561
-
562
- koa-classic-server follows the principle: **"if a file is in `rootDir`, `GET` on its path returns it"**. The defaults serve files without applying surprise restrictions — the operator is the source of truth. See [`CLAUDE.md`](./CLAUDE.md) for the full design philosophy.
563
-
564
- This means hardening is **opt-in via explicit configuration**. The checklist below covers the most common production concerns. Each item is one or two lines of configuration; not all of them apply to every deployment.
565
-
566
- #### ✅ Static site / public asset serving
567
-
568
- - [ ] **Hide dot-files** that may contain secrets:
569
- `hidden: { dotFiles: { default: 'hidden', whitelist: ['.well-known'] } }`
570
- - [ ] **Block dot-directories** like `.git`:
571
- `hidden: { dotDirs: { default: 'hidden', whitelist: ['.well-known'] } }`
572
- - [ ] **Disable directory listing** in production:
573
- `dirListing: { enabled: false }` (combine with an `index` file)
574
- - [ ] **Enable browser HTTP caching**:
575
- `browserCacheEnabled: true, browserCacheMaxAge: 86400`
576
- - [ ] **Restrict methods** to read-only (default already `['GET']`):
577
- `method: ['GET', 'HEAD']`
578
- - [ ] **Reserve sensitive paths** for app routes:
579
- `urlsReserved: ['/api', '/admin']`
580
- - [ ] **Add upstream security headers** for user-served HTML (not auto-added by this middleware — see *DNS Rebinding / Headers* in `docs/DOCUMENTATION.md`).
581
-
582
- #### ✅ User uploads, multi-tenant, untrusted-write directories
583
-
584
- - [ ] **Lower the entry cap** for accidentally-large dirs:
585
- `dirListing: { maxEntries: 1000 }` (default 100000 is a safety net, not a security feature)
586
- - [ ] **Hide dot-files at every depth**:
587
- `hidden: { dotFiles: { default: 'hidden' }, dotDirs: { default: 'hidden' } }`
588
- - [ ] **Add path-aware blocklists** for known secret patterns:
589
- `hidden: { alwaysHide: ['*.key', '*.pem', /\.secret$/, 'config/secrets/**'] }`
590
- - [ ] **Monitor directory growth externally** (cron + alert) — the v3.0 cap bounds rendering CPU but not the initial `readdir()` allocation. See `[F-1]` in `docs/security_improvement_for_V3.md` for the v3.1 streaming-read opt-in tracking this gap.
591
-
592
- #### ✅ Production hygiene (any deployment)
585
+ ### Hardening & production configuration
593
586
 
594
- - [ ] **Validate `Host` header upstream** (nginx `server_name` allowlist or app-level middleware)this middleware does NOT validate `Host`. See *DNS Rebinding* in `docs/DOCUMENTATION.md`.
595
- - [ ] **Disable template-engine in production** if you don't use SSR — minimizes attack surface:
596
- omit the `template` option entirely
597
- - [ ] **Tune `template.renderTimeout`** if you do use SSR — default 30 s is conservative; tighten for tight-SLA services
598
- - [ ] **Inject a real logger** instead of `console`:
599
- `logger: pino()` so security-relevant warnings reach your aggregation
600
- - [ ] **Pin the latest patch version** in `package.json` and run `npm audit` in CI
587
+ koa-classic-server follows the principle **"if a file is in `rootDir`, `GET` on its path returns it"** defaults are transparent and the operator is the source of truth (see [`CLAUDE.md`](./CLAUDE.md)). Hardening is therefore **opt-in via explicit configuration**.
601
588
 
602
- ### Suggested production security configuration
589
+ The full, canonical hardening reference lives in one place to avoid drift:
603
590
 
604
- A single configuration block that covers most production deployments. Start here and tune for your workload (static site vs uploads vs internal admin):
605
-
606
- ```javascript
607
- const Koa = require('koa');
608
- const pino = require('pino')({ level: 'info' });
609
- const path = require('path');
610
- const koaClassicServer = require('koa-classic-server');
611
-
612
- const app = new Koa();
613
-
614
- // 1) Validate Host header — mitigates DNS rebinding on LAN / loopback exposure.
615
- const ALLOWED_HOSTS = new Set([
616
- 'app.example.com',
617
- 'localhost:3000',
618
- ]);
619
- app.use(async (ctx, next) => {
620
- if (!ALLOWED_HOSTS.has(ctx.host)) {
621
- ctx.status = 421;
622
- ctx.body = 'Misdirected Request';
623
- return;
624
- }
625
- await next();
626
- });
627
-
628
- // 2) Apply security headers to user-served HTML/JS/CSS. The middleware
629
- // sets these only on its own generated pages (listing + errors).
630
- app.use(async (ctx, next) => {
631
- ctx.set('X-Content-Type-Options', 'nosniff');
632
- ctx.set('Referrer-Policy', 'strict-origin-when-cross-origin');
633
- ctx.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
634
- await next();
635
- });
636
-
637
- // 3) The file server with hardened defaults.
638
- app.use(koaClassicServer(path.join(__dirname, 'public'), {
639
- method: ['GET', 'HEAD'], // read-only
640
-
641
- index: ['index.html'], // serve index when present
642
-
643
- dirListing: {
644
- enabled: process.env.NODE_ENV !== 'production',
645
- maxEntries: 10000, // tighten the soft cap below the 100k default
646
- entriesPerPage: 100,
647
- },
648
-
649
- hidden: {
650
- dotFiles: {
651
- default: 'hidden', // hide .env / .htaccess / etc by default
652
- whitelist: ['.well-known'], // expose ACME / Let's Encrypt
653
- },
654
- dotDirs: {
655
- default: 'hidden',
656
- whitelist: ['.well-known'],
657
- },
658
- alwaysHide: ['*.key', '*.pem', /^backup-/, /\.secret$/],
659
- },
660
-
661
- browserCacheEnabled: true,
662
- browserCacheMaxAge: 86400, // 24 h — bandwidth savings on cache hits
663
-
664
- logger: pino, // pipe internal warnings to structured logs
665
-
666
- urlsReserved: ['/api', '/admin'], // routes handled by other middleware
667
- }));
668
-
669
- app.listen(3000);
670
- ```
591
+ 📖 **[Security Hardening Guide `docs/SECURITY_HARDENING.md`](./docs/SECURITY_HARDENING.md)**
671
592
 
672
- For multi-tenant or user-upload scenarios, also drop `dirListing.maxEntries` to `1000` and monitor the served directory's size externally.
593
+ It covers a threat-model-based approach (trusted content / internal tool / user-uploads &
594
+ multi-tenant), per-topic recommendations (dot-files, symlinks, listings & directory size,
595
+ `nosniff`, `Host`/DNS rebinding, and more), per-profile checklists, residual risks, and a
596
+ **copy-paste maximally-hardened configuration**.
673
597
 
674
598
  ---
675
599
 
@@ -959,8 +883,8 @@ Contributions are welcome:
959
883
  ## Known Limitations
960
884
 
961
885
  - `urlsReserved` only matches first-level path segments
962
- - The middleware does not validate the `Host` header — configure a reverse proxy or an upstream allowlist (see [DOCUMENTATION.md → DNS Rebinding](./docs/DOCUMENTATION.md#dns-rebinding--valida-lheader-host-a-monte))
963
- - Static files are returned without security headers — apply your own upstream middleware (see [DOCUMENTATION.md Limiti dei Security Headers](./docs/DOCUMENTATION.md#limiti-dei-security-headers-sui-file-statici))
886
+ - The middleware does not validate the `Host` header — configure a reverse proxy or an upstream allowlist (see [Security Hardening Guide Host validation / DNS rebinding](./docs/SECURITY_HARDENING.md#36-host-validation--dns-rebinding))
887
+ - Static files are returned without security headers by default enable `staticSecurityHeaders: { nosniff: true }` and/or apply upstream middleware (see [Security Hardening Guide Security headers on static files](./docs/SECURITY_HARDENING.md#35-security-headers-on-static-files))
964
888
 
965
889
  See [DEBUG_REPORT.md](./docs/DEBUG_REPORT.md) for technical details.
966
890
 
package/index.cjs CHANGED
@@ -158,6 +158,14 @@ function sendNotFound(ctx) {
158
158
  ctx.body = _NOT_FOUND_HTML;
159
159
  }
160
160
 
161
+ // Plain-text 400 for malformed requests (bad percent-encoding, invalid Host,
162
+ // null byte). Kept minimal and header-light to match the existing null-byte /
163
+ // traversal guards — the response body carries no attacker-controlled data.
164
+ function sendBadRequest(ctx) {
165
+ ctx.status = 400;
166
+ ctx.body = 'Bad Request';
167
+ }
168
+
161
169
  // Validates and returns a logger compatible with our contract. The minimum
162
170
  // surface is `{ error: Function, warn: Function }` — any object exposing both
163
171
  // (console, pino, winston, bunyan, ...) is accepted as-is.
@@ -198,6 +206,28 @@ function sendTemplateError(ctx, status, html, logMsg, err, logger) {
198
206
  ctx.body = html;
199
207
  }
200
208
 
209
+ // Rewrites an already-rendered response into an RFC 9110 §9.3.2 compliant HEAD
210
+ // response: the status and headers produced by the render are preserved, the
211
+ // body is replaced with an empty buffer (so no content is sent), and
212
+ // Content-Length is restored to the byte length the GET body would have had.
213
+ // Reassigning ctx.body to a non-stream value also makes Koa auto-destroy a
214
+ // previous stream body, so no file descriptor leaks. Stream / non-buffer bodies
215
+ // (uncommon for template renders) carry no Content-Length, matching the static
216
+ // streaming-HEAD branch.
217
+ function stripBodyForHead(ctx) {
218
+ if (ctx.headerSent) return; // render already flushed — status/headers are locked
219
+ const body = ctx.body;
220
+ if (body == null) return; // render produced no body (redirect, pass-through, ...) — leave status as-is
221
+ const hasKnownLength = typeof body === 'string' || Buffer.isBuffer(body);
222
+ const length = hasKnownLength ? Buffer.byteLength(body) : null;
223
+ ctx.body = Buffer.alloc(0);
224
+ if (length !== null) {
225
+ ctx.set('Content-Length', String(length)); // body setter zeroed it — restore the real length
226
+ } else {
227
+ ctx.remove('Content-Length'); // unknown length — omit, like static streaming HEAD
228
+ }
229
+ }
230
+
201
231
  // Attempts to render the requested file through the user's template engine.
202
232
  // Returns true if the request was handled (success, timeout, or error response
203
233
  // already written), false if no template applies (caller should continue with
@@ -214,6 +244,16 @@ async function tryRenderTemplate(ctx, next, filePath, rawBuffer, templateOpts, l
214
244
  const fileExt = path.extname(filePath).slice(1);
215
245
  if (!fileExt || !templateOpts.ext.includes(fileExt)) return false;
216
246
 
247
+ // RFC 9110 §9.3.2: HEAD must mirror GET (same status + headers, no body). The
248
+ // user's render is run exactly as for GET — by presenting ctx.method as GET for
249
+ // the duration of the render — so it resolves, validates, and sets Content-Type
250
+ // / status identically; stripBodyForHead() then discards the body and restores
251
+ // Content-Length. Without this, a render that early-returns on non-GET never
252
+ // sets ctx.body, leaving ctx.status at Koa's default 404 for HEAD even though
253
+ // GET returns 200.
254
+ const isHeadRequest = ctx.method === 'HEAD';
255
+ if (isHeadRequest) ctx.method = 'GET';
256
+
217
257
  const controller = new AbortController();
218
258
  const onClientClose = () => controller.abort();
219
259
  ctx.req.on('close', onClientClose);
@@ -255,6 +295,10 @@ async function tryRenderTemplate(ctx, next, filePath, rawBuffer, templateOpts, l
255
295
  } finally {
256
296
  if (timer) clearTimeout(timer);
257
297
  ctx.req.removeListener('close', onClientClose);
298
+ if (isHeadRequest) {
299
+ ctx.method = 'HEAD';
300
+ stripBodyForHead(ctx);
301
+ }
258
302
  }
259
303
 
260
304
  return true;
@@ -487,10 +531,20 @@ module.exports = function koaClassicServer(
487
531
  opts STRUCTURE
488
532
  opts = {
489
533
  method: ['GET'], // Supported methods, otherwise next() will be called
534
+ symlinks: 'follow', // Symlink policy (V3.1+). Opt-in protection against symlink escape:
535
+ // 'follow' (default) — follow symlinks anywhere, incl.
536
+ // targets OUTSIDE rootDir. Zero overhead;
537
+ // historical behavior (no rootDir existence check).
538
+ // 'follow-within-root' — follow only while the resolved realpath stays
539
+ // inside rootDir; escaping links return 404.
540
+ // 'deny' — never follow a symlink resolved below rootDir
541
+ // (rootDir being a symlink itself is always allowed).
542
+ // Protected modes cost one fs.realpath() per served path and require
543
+ // rootDir to exist at factory time. See README "Security Checklist".
490
544
  dirListing: { // Directory listing configuration (V3+).
491
545
  enabled: true, // Render the directory listing HTML when no index file matches.
492
546
  // Set to false to return 404 instead of a listing.
493
- maxEntries: 100000, // Soft cap on entries shown / sorted / stat'd per listing.
547
+ maxEntries: 10000, // Soft cap on entries shown / sorted / stat'd per listing.
494
548
  // Implementation: fs.promises.readdir() then slice(0, maxEntries).
495
549
  // This is a SAFETY NET against catastrophic operational accidents
496
550
  // (broken log rotation, mistakenly mounted huge FS) — not a policy
@@ -577,6 +631,14 @@ module.exports = function koaClassicServer(
577
631
  mimeTypes: [], // compressible MIME types (replaces default list if provided)
578
632
  },
579
633
  // compression: false // shorthand to disable all compression
634
+ staticSecurityHeaders: { // Opt-in security headers on STATIC file responses (V3.1+).
635
+ nosniff: false, // false (default) — no change. true → sets
636
+ // 'X-Content-Type-Options: nosniff' on 200/206/304 static
637
+ // responses, stopping MIME sniffing (content-sniffing XSS on
638
+ // user-uploaded files). Does NOT apply to template-rendered
639
+ // output. Off by default: hardening is opt-in. Other headers
640
+ // (X-Frame-Options, HSTS, ...) stay with the reverse proxy.
641
+ },
580
642
  logger: console, // Logger used for internal errors and warnings.
581
643
  // Must expose error(...) and warn(...). Pass pino/winston/bunyan
582
644
  // or any compatible object to integrate with aggregated logging.
@@ -1030,6 +1092,100 @@ module.exports = function koaClassicServer(
1030
1092
  return { rawFile, compressedFile };
1031
1093
  }
1032
1094
 
1095
+ // ── symlinks policy (V3.1+) — opt-in protection against symlink escape ──
1096
+ // 'follow' (default) : follow symlinks anywhere, including targets
1097
+ // outside rootDir. Zero overhead — current behavior.
1098
+ // 'follow-within-root' : follow symlinks only while the resolved realpath
1099
+ // stays inside rootDir; escaping links return 404.
1100
+ // 'deny' : never follow a symlink resolved BELOW rootDir
1101
+ // (rootDir itself being a symlink is always allowed).
1102
+ // Protected modes cost one fs.realpath() per served path. See the Security Checklist.
1103
+ if (options.symlinks === undefined) {
1104
+ options.symlinks = 'follow';
1105
+ } else if (
1106
+ options.symlinks !== 'follow' &&
1107
+ options.symlinks !== 'follow-within-root' &&
1108
+ options.symlinks !== 'deny'
1109
+ ) {
1110
+ throw new Error(
1111
+ '[koa-classic-server] options.symlinks must be one of "follow", "follow-within-root", "deny". ' +
1112
+ 'Got: ' + String(options.symlinks)
1113
+ );
1114
+ }
1115
+ const _symlinkMode = options.symlinks;
1116
+
1117
+ // realpath of rootDir, resolved once at init (pinned). This is what makes a
1118
+ // rootDir that is ITSELF a symlink work in protected modes: the boundary check
1119
+ // compares against the real target, not the (unresolved) symlink path.
1120
+ // In 'follow' mode we skip this entirely (and keep the historical behavior of
1121
+ // NOT requiring rootDir to exist at factory time).
1122
+ let realRootDir = normalizedRootDir;
1123
+ if (_symlinkMode !== 'follow') {
1124
+ try {
1125
+ realRootDir = fs.realpathSync.native(normalizedRootDir);
1126
+ } catch (err) {
1127
+ throw new Error(
1128
+ `[koa-classic-server] rootDir must exist when symlinks mode is "${_symlinkMode}". ` +
1129
+ 'Original error: ' + err.message
1130
+ );
1131
+ }
1132
+ }
1133
+
1134
+ // Case-insensitive path comparison on filesystems that are case-insensitive by
1135
+ // default (APFS/HFS+ on macOS, NTFS on Windows) — otherwise a casing mismatch
1136
+ // between realRootDir and a resolved path would produce spurious 404s.
1137
+ const _caseInsensitiveFS = process.platform === 'darwin' || process.platform === 'win32';
1138
+
1139
+ function _isWithinRoot(resolved, root) {
1140
+ if (_caseInsensitiveFS) {
1141
+ resolved = resolved.toLowerCase();
1142
+ root = root.toLowerCase();
1143
+ }
1144
+ if (resolved === root) return true;
1145
+ const withSep = root.endsWith(path.sep) ? root : root + path.sep;
1146
+ return resolved.startsWith(withSep);
1147
+ }
1148
+
1149
+ // Returns true if serving `resolvedPath` is permitted under the current symlinks
1150
+ // policy. In 'follow' mode this is a no-op (no syscall). In protected modes it
1151
+ // resolves the realpath and checks it against realRootDir.
1152
+ async function symlinkAllowed(resolvedPath) {
1153
+ if (_symlinkMode === 'follow') return true;
1154
+ let real;
1155
+ try {
1156
+ real = await fs.promises.realpath(resolvedPath);
1157
+ } catch {
1158
+ return false; // cannot resolve (broken/circular link, race) → treat as not found
1159
+ }
1160
+ if (_symlinkMode === 'deny') {
1161
+ // Reject if ANY symlink was resolved below rootDir: the real path must equal
1162
+ // the path obtained by swapping only the rootDir prefix (no inner resolution).
1163
+ const rel = path.relative(normalizedRootDir, resolvedPath);
1164
+ const expected = path.join(realRootDir, rel);
1165
+ return _caseInsensitiveFS
1166
+ ? real.toLowerCase() === expected.toLowerCase()
1167
+ : real === expected;
1168
+ }
1169
+ // 'follow-within-root': the resolved target must stay inside rootDir.
1170
+ return _isWithinRoot(real, realRootDir);
1171
+ }
1172
+
1173
+ // ── staticSecurityHeaders (V3.1+) — opt-in security headers on static responses ──
1174
+ // Off by default (design philosophy: hardening is opt-in, documentation over defaults).
1175
+ // Currently supports `nosniff` (X-Content-Type-Options: nosniff), which stops browsers
1176
+ // from MIME-sniffing a response and interpreting it against the declared Content-Type
1177
+ // (a content-sniffing XSS vector when serving user-uploaded files). Other headers
1178
+ // (X-Frame-Options, Referrer-Policy, HSTS) remain the reverse proxy's responsibility.
1179
+ const _ssh = options.staticSecurityHeaders;
1180
+ if (_ssh !== undefined && (typeof _ssh !== 'object' || _ssh === null || Array.isArray(_ssh))) {
1181
+ throw new Error(
1182
+ '[koa-classic-server] options.staticSecurityHeaders must be an object, e.g. { nosniff: true }.'
1183
+ );
1184
+ }
1185
+ const staticSecurityHeaders = {
1186
+ nosniff: !!(_ssh && _ssh.nosniff),
1187
+ };
1188
+
1033
1189
  const compressionConfig = normalizeCompressionConfig(options.compression);
1034
1190
  const serverCacheConfig = normalizeServerCacheConfig(options.serverCache);
1035
1191
 
@@ -1152,11 +1308,18 @@ module.exports = function koaClassicServer(
1152
1308
  const urlToUse = options.useOriginalUrl ? ctx.originalUrl : ctx.url;
1153
1309
  const _origin = ctx.protocol + '://' + ctx.host;
1154
1310
  const fullUrl = _origin + urlToUse;
1311
+ // Parse the request URL. `new URL()` throws on an invalid Host header
1312
+ // (e.g. "Host: bad host") — reject as 400 rather than letting it surface as 500.
1155
1313
  let pageHref = '';
1156
- if (fullUrl.charAt(fullUrl.length - 1) === '/') {
1157
- pageHref = new URL(fullUrl.slice(0, -1));
1158
- } else {
1159
- pageHref = new URL(fullUrl);
1314
+ try {
1315
+ if (fullUrl.charAt(fullUrl.length - 1) === '/') {
1316
+ pageHref = new URL(fullUrl.slice(0, -1));
1317
+ } else {
1318
+ pageHref = new URL(fullUrl);
1319
+ }
1320
+ } catch {
1321
+ sendBadRequest(ctx);
1322
+ return;
1160
1323
  }
1161
1324
 
1162
1325
  // Check URL prefix
@@ -1175,7 +1338,12 @@ module.exports = function koaClassicServer(
1175
1338
  let a_pathnameOutPrefix = a_pathname.slice(_urlPrefixParts.length);
1176
1339
  let s_pathnameOutPrefix = a_pathnameOutPrefix.join("/");
1177
1340
  let hrefOutPrefix = pageHref.origin + '/' + s_pathnameOutPrefix;
1178
- pageHrefOutPrefix = new URL(hrefOutPrefix);
1341
+ try {
1342
+ pageHrefOutPrefix = new URL(hrefOutPrefix);
1343
+ } catch {
1344
+ sendBadRequest(ctx);
1345
+ return;
1346
+ }
1179
1347
  }
1180
1348
 
1181
1349
  // Check reserved URLs (first level only)
@@ -1194,26 +1362,37 @@ module.exports = function koaClassicServer(
1194
1362
  if (pageHrefOutPrefix.pathname === "/") {
1195
1363
  requestedPath = "";
1196
1364
  } else {
1197
- requestedPath = decodeURIComponent(pageHrefOutPrefix.pathname);
1365
+ // decodeURIComponent() throws URIError on malformed percent-encoding
1366
+ // (e.g. "/%", "/%zz", a truncated UTF-8 sequence) — reject as 400
1367
+ // rather than letting it surface as an unhandled 500.
1368
+ try {
1369
+ requestedPath = decodeURIComponent(pageHrefOutPrefix.pathname);
1370
+ } catch {
1371
+ sendBadRequest(ctx);
1372
+ return;
1373
+ }
1198
1374
  }
1199
1375
 
1200
1376
  // Null byte guard: path.normalize() throws ERR_INVALID_ARG_VALUE for paths
1201
1377
  // containing \0. Reject early with 400 Bad Request before it reaches fs calls.
1202
1378
  if (requestedPath.includes('\0')) {
1203
- ctx.status = 400;
1204
- ctx.body = 'Bad Request';
1379
+ sendBadRequest(ctx);
1205
1380
  return;
1206
1381
  }
1207
1382
 
1208
1383
  const normalizedPath = path.normalize(requestedPath);
1209
1384
  const fullPath = path.join(normalizedRootDir, normalizedPath);
1210
1385
 
1211
- // Security check: ensure resolved path is within rootDir.
1386
+ // Security check: ensure resolved path is within rootDir. Uses the shared
1387
+ // _isWithinRoot() helper, which is boundary-aware: it matches rootDir exactly
1388
+ // or rootDir + path.sep, never a sibling (e.g. /srv/wwwsecret for root /srv/www) —
1389
+ // hardened defense in depth against a future change to how fullPath is built.
1212
1390
  // Covers: ../ traversal, URL-encoded variants (%2e%2e%2f), and on Windows
1213
1391
  // backslash sequences (path.normalize converts \ to / before the check).
1214
- if (!fullPath.startsWith(normalizedRootDir)) {
1215
- ctx.status = 403;
1216
- ctx.body = 'Forbidden';
1392
+ // Returns 404 (not 403) so "outside root" is indistinguishable from "not found",
1393
+ // matching the symlink-escape and hidden-entry outcomes.
1394
+ if (!_isWithinRoot(fullPath, normalizedRootDir)) {
1395
+ sendNotFound(ctx);
1217
1396
  return;
1218
1397
  }
1219
1398
 
@@ -1289,8 +1468,8 @@ module.exports = function koaClassicServer(
1289
1468
  if (!extOfRequested && requestedPath !== '' && !requestedPath.endsWith('/') && !hadTrailingSlash) {
1290
1469
  const pathWithExt = fullPath + hideExt;
1291
1470
 
1292
- // Security check: ensure resolved path is still within rootDir
1293
- if (pathWithExt.startsWith(normalizedRootDir)) {
1471
+ // Security check: ensure resolved path is still within rootDir (boundary-aware)
1472
+ if (_isWithinRoot(pathWithExt, normalizedRootDir)) {
1294
1473
  try {
1295
1474
  const statWithExt = await fs.promises.stat(pathWithExt);
1296
1475
  if (statWithExt.isFile()) {
@@ -1324,6 +1503,15 @@ module.exports = function koaClassicServer(
1324
1503
  }
1325
1504
  }
1326
1505
 
1506
+ // Symlink boundary check (V-1): in protected modes reject any requested file
1507
+ // or directory whose realpath escapes rootDir (or, in 'deny' mode, any symlink
1508
+ // resolved below rootDir). The `_symlinkMode !== 'follow'` guard short-circuits
1509
+ // before any await so the default 'follow' mode stays truly zero-overhead.
1510
+ if (_symlinkMode !== 'follow' && !(await symlinkAllowed(toOpen))) {
1511
+ sendNotFound(ctx);
1512
+ return;
1513
+ }
1514
+
1327
1515
  if (stat.isDirectory()) {
1328
1516
  // Handle directory
1329
1517
  if (options.dirListing.enabled) {
@@ -1334,6 +1522,13 @@ module.exports = function koaClassicServer(
1334
1522
  const indexRelPath = path.relative(normalizedRootDir, path.join(toOpen, indexFile.name)).split(path.sep).join('/');
1335
1523
  if (!isHiddenEntry(indexFile.name, indexRelPath, false)) {
1336
1524
  const indexPath = path.join(toOpen, indexFile.name);
1525
+ // Symlink boundary check (V-1): an index file may itself be a
1526
+ // symlink escaping rootDir — validate before serving it.
1527
+ // Guarded so the default 'follow' mode skips the await entirely.
1528
+ if (_symlinkMode !== 'follow' && !(await symlinkAllowed(indexPath))) {
1529
+ sendNotFound(ctx);
1530
+ return;
1531
+ }
1337
1532
  await loadFile(indexPath, indexFile.stat);
1338
1533
  return;
1339
1534
  }
@@ -1406,6 +1601,15 @@ module.exports = function koaClassicServer(
1406
1601
  // Advertise range support on all file responses (including 304)
1407
1602
  ctx.set('Accept-Ranges', 'bytes');
1408
1603
 
1604
+ // Opt-in static security headers (V-4). Applies to all static file
1605
+ // responses (200 / 206 / 304). Placed after the template early-return,
1606
+ // so template-rendered output is unaffected (that is the operator's
1607
+ // responsibility inside their render function). Off by default — see
1608
+ // the design philosophy: hardening is opt-in, not a default.
1609
+ if (staticSecurityHeaders.nosniff) {
1610
+ ctx.set('X-Content-Type-Options', 'nosniff');
1611
+ }
1612
+
1409
1613
  // Cache-Control set early — applies to all responses (200, 206, 304)
1410
1614
  if (options.browserCacheEnabled) {
1411
1615
  ctx.set('Cache-Control', `public, max-age=${options.browserCacheMaxAge}, must-revalidate`);
@@ -1802,10 +2006,18 @@ module.exports = function koaClassicServer(
1802
2006
  const itemRelPath = dirRelPath ? dirRelPath + '/' + s_name : s_name;
1803
2007
  if (isHiddenEntry(s_name, itemRelPath, itemIsDir)) return null;
1804
2008
 
2009
+ // Symlink boundary (V-1): in protected modes, flag entries whose
2010
+ // target escapes rootDir (or, in 'deny' mode, any symlink). Blocked
2011
+ // entries are rendered non-clickable and do not expose the target size.
2012
+ let isBlockedSymlink = false;
2013
+ if (_symlinkMode !== 'follow' && !isBrokenSymlink && (type === 3 || type === 0)) {
2014
+ if (!(await symlinkAllowed(itemPath))) isBlockedSymlink = true;
2015
+ }
2016
+
1805
2017
  // Get file size — reuse cachedStat if already available (avoids double stat for symlinks)
1806
2018
  let sizeStr = '-';
1807
2019
  let sizeBytes = 0;
1808
- if (!isBrokenSymlink) {
2020
+ if (!isBrokenSymlink && !isBlockedSymlink) {
1809
2021
  try {
1810
2022
  const itemStat = cachedStat || await fs.promises.stat(itemPath);
1811
2023
  if (effectiveType === 1) {
@@ -1826,6 +2038,7 @@ module.exports = function koaClassicServer(
1826
2038
  effectiveType,
1827
2039
  isSymlink: type === 3,
1828
2040
  isBrokenSymlink,
2041
+ isBlockedSymlink,
1829
2042
  mimeType,
1830
2043
  sizeStr,
1831
2044
  sizeBytes,
@@ -1887,14 +2100,16 @@ module.exports = function koaClassicServer(
1887
2100
  // Symlink indicator label
1888
2101
  const symlinkLabel = item.isBrokenSymlink
1889
2102
  ? ' ( Broken Symlink )'
1890
- : item.isSymlink
1891
- ? ' ( Symlink )'
1892
- : '';
2103
+ : item.isBlockedSymlink
2104
+ ? ' ( Blocked Symlink )'
2105
+ : item.isSymlink
2106
+ ? ' ( Symlink )'
2107
+ : '';
1893
2108
 
1894
2109
  if (item.isReserved) {
1895
2110
  parts.push(`${rowStart} ${escapeHtml(item.name)}${symlinkLabel}</td> <td> DIR BUT RESERVED</td><td>${item.sizeStr}</td></tr>`);
1896
- } else if (item.isBrokenSymlink) {
1897
- // Broken symlink: name visible but not clickable
2111
+ } else if (item.isBrokenSymlink || item.isBlockedSymlink) {
2112
+ // Broken or policy-blocked symlink: name visible but not clickable
1898
2113
  parts.push(`${rowStart} ${escapeHtml(item.name)}${symlinkLabel}</td> <td> ${escapeHtml(item.mimeType)} </td><td>${item.sizeStr}</td></tr>`);
1899
2114
  } else {
1900
2115
  parts.push(`${rowStart} <a href="${escapeHtml(item.itemUri)}">${escapeHtml(item.name)}</a>${symlinkLabel} </td> <td> ${escapeHtml(item.mimeType)} </td><td>${item.sizeStr}</td></tr>`);