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.
- package/README.md +44 -120
- package/index.cjs +236 -21
- package/package.json +10 -5
- package/.github/workflows/npm-publish.yml +0 -98
- package/CLAUDE.md +0 -101
- package/__tests__/benchmark-results-baseline-v1.2.0.txt +0 -354
- package/__tests__/benchmark-results-optimized-v2.0.0.txt +0 -354
- package/__tests__/benchmark-results-v3.0.0.txt +0 -372
- package/__tests__/benchmark.js +0 -239
- package/__tests__/caching-headers.test.js +0 -556
- package/__tests__/compression-fixtures/data.json +0 -1
- package/__tests__/compression-fixtures/large.txt +0 -1
- package/__tests__/compression-fixtures/small.txt +0 -1
- package/__tests__/compression.test.js +0 -284
- package/__tests__/customTest/README.md +0 -6
- package/__tests__/customTest/loadConfig.util.js +0 -41
- package/__tests__/customTest/serversToLoad.util.js +0 -93
- package/__tests__/demo-regex-index.js +0 -140
- package/__tests__/deprecation-warnings.test.js +0 -105
- package/__tests__/directory-sorting-links.test.js +0 -135
- package/__tests__/dt-unknown.test.js +0 -635
- package/__tests__/ejs.test.js +0 -299
- package/__tests__/hidden-fixtures/.dot-dir/inside.txt +0 -1
- package/__tests__/hidden-fixtures/.env +0 -2
- package/__tests__/hidden-fixtures/.well-known/acme-challenge.txt +0 -1
- package/__tests__/hidden-fixtures/config/secrets/password.txt +0 -1
- package/__tests__/hidden-fixtures/data.key +0 -1
- package/__tests__/hidden-fixtures/file.secret +0 -1
- package/__tests__/hidden-fixtures/index.html +0 -1
- package/__tests__/hidden-fixtures/normal.txt +0 -1
- package/__tests__/hidden-fixtures/subdir/.env +0 -1
- package/__tests__/hidden-fixtures/subdir/regular.txt +0 -1
- package/__tests__/hidden-option.test.js +0 -407
- package/__tests__/hideExtension.test.js +0 -607
- package/__tests__/index-option.test.js +0 -449
- package/__tests__/index.test.js +0 -345
- package/__tests__/listing.test.js +0 -437
- package/__tests__/logger.test.js +0 -232
- package/__tests__/performance.test.js +0 -370
- package/__tests__/publicWwwTest/cartella/sottocartella/ciao.html +0 -11
- package/__tests__/publicWwwTest/cartella/sottocartella/provaEjs/testEjs.ejs +0 -11
- package/__tests__/publicWwwTest/cartella vuota con spazi nel nome/file con spazio nel nome .txt +0 -1
- package/__tests__/publicWwwTest/cartella vuota con spazi nel nome /file con spazio nel nome .txt +0 -1
- package/__tests__/publicWwwTest/ejs-templates/complex.ejs +0 -56
- package/__tests__/publicWwwTest/ejs-templates/index.ejs +0 -30
- package/__tests__/publicWwwTest/ejs-templates/simple.ejs +0 -13
- package/__tests__/publicWwwTest/ejs-templates/with-conditional.ejs +0 -28
- package/__tests__/publicWwwTest/ejs-templates/with-escaping.ejs +0 -26
- package/__tests__/publicWwwTest/ejs-templates/with-loop.ejs +0 -16
- package/__tests__/publicWwwTest/hideext-test/about/index.html +0 -1
- package/__tests__/publicWwwTest/hideext-test/about.EJS +0 -1
- package/__tests__/publicWwwTest/hideext-test/about.ejs +0 -2
- package/__tests__/publicWwwTest/hideext-test/blog/articolo.ejs +0 -2
- package/__tests__/publicWwwTest/hideext-test/conflict-test/pagina +0 -1
- package/__tests__/publicWwwTest/hideext-test/conflict-test/pagina.ejs +0 -1
- package/__tests__/publicWwwTest/hideext-test/file.ejs.bak +0 -1
- package/__tests__/publicWwwTest/hideext-test/index.ejs +0 -2
- package/__tests__/publicWwwTest/hideext-test/photo.txt +0 -1
- package/__tests__/publicWwwTest/hideext-test/sezione/index.ejs +0 -1
- package/__tests__/publicWwwTest/hideext-test/style.css +0 -1
- package/__tests__/publicWwwTest/ile_vuoto.txt +0 -0
- package/__tests__/publicWwwTest/index.html +0 -11
- package/__tests__/publicWwwTest/prova file .txt +0 -2
- package/__tests__/publicWwwTest/semplicetxt.txt +0 -1
- package/__tests__/publicWwwTest/test-page.html +0 -1
- package/__tests__/publicWwwTest/test.txt +0 -1
- package/__tests__/range-fixtures/sample.txt +0 -1
- package/__tests__/range.test.js +0 -223
- package/__tests__/security-headers.test.js +0 -165
- package/__tests__/security.test.js +0 -322
- package/__tests__/server-cache-fixtures/large.txt +0 -1
- package/__tests__/server-cache-fixtures/small.txt +0 -1
- package/__tests__/server-cache.test.js +0 -594
- package/__tests__/setup-benchmark.js +0 -178
- package/__tests__/symlink.test.js +0 -441
- package/__tests__/template-timeout.test.js +0 -321
- package/__tests__/test-regex-quick.js +0 -158
- package/__tests__/useOriginalUrl.test.js +0 -213
- package/docs/ACTION_PLAN.md +0 -293
- package/docs/BENCHMARKS.md +0 -317
- package/docs/CHANGELOG.md +0 -880
- package/docs/CODE_REVIEW.md +0 -300
- package/docs/DEBUG_REPORT.md +0 -593
- package/docs/DOCUMENTATION.md +0 -1864
- package/docs/EXAMPLES_INDEX_OPTION.md +0 -395
- package/docs/FLOW_DIAGRAM.md +0 -954
- package/docs/INDEX_OPTION_PRIORITY.md +0 -527
- package/docs/OPTIMIZATION_HTTP_CACHING.md +0 -689
- package/docs/OPTIMIZATION_ROADMAP_for_V3.md +0 -864
- package/docs/PERFORMANCE_ANALYSIS.md +0 -839
- package/docs/PERFORMANCE_COMPARISON.md +0 -388
- package/docs/noteExports.md +0 -148
- package/docs/security_improvement_for_V3.md +0 -421
- package/docs/template-engine/TEMPLATE_ENGINE_GUIDE.md +0 -1734
- package/docs/template-engine/esempi-incrementali.js +0 -192
- package/docs/template-engine/examples/esempio1-nessun-dato.ejs +0 -12
- package/docs/template-engine/examples/esempio2-una-variabile.ejs +0 -11
- package/docs/template-engine/examples/esempio3-piu-variabili.ejs +0 -15
- package/docs/template-engine/examples/esempio4-condizionale.ejs +0 -18
- package/docs/template-engine/examples/esempio5-loop.ejs +0 -18
- package/docs/template-engine/examples/index-esempi.html +0 -181
- package/docs/template-engine/examples/index.html +0 -40
- package/docs/template-engine/examples/test.ejs +0 -64
- package/eslint.config.mjs +0 -17
- package/jest.config.js +0 -18
package/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 `
|
|
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'`.
|
|
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 →
|
|
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 →
|
|
504
|
-
GET /%2e%2e%2fpackage.json →
|
|
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.
|
|
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 [
|
|
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
|
-
###
|
|
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
|
-
-
|
|
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
|
-
|
|
589
|
+
The full, canonical hardening reference lives in one place to avoid drift:
|
|
603
590
|
|
|
604
|
-
|
|
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
|
-
|
|
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 [
|
|
963
|
-
- Static files are returned without security headers —
|
|
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:
|
|
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
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
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
|
|
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.
|
|
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>`);
|