st 3.0.2 → 3.0.4

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.
@@ -10,9 +10,9 @@ jobs:
10
10
  runs-on: ${{ matrix.os }}
11
11
  steps:
12
12
  - name: Checkout Repository
13
- uses: actions/checkout@v4
13
+ uses: actions/checkout@v7
14
14
  - name: Use Node.js ${{ matrix.node }}
15
- uses: actions/setup-node@v4
15
+ uses: actions/setup-node@v6
16
16
  with:
17
17
  node-version: ${{ matrix.node }}
18
18
  - name: Install Dependencies
package/README.md CHANGED
@@ -129,7 +129,7 @@ const mount = st({
129
129
  },
130
130
 
131
131
  content: {
132
- max: 1024*1024*64, // how much memory to use on caching contents
132
+ maxSize: 1024*1024*64, // how much memory to use on caching contents
133
133
  maxAge: 1000 * 60 * 10, // how long to cache contents for
134
134
  // if `false` does not set cache control headers
135
135
  cacheControl: 'public, max-age=600' // to set an explicit cache-control
@@ -137,12 +137,12 @@ const mount = st({
137
137
  },
138
138
 
139
139
  index: { // irrelevant if not using index:true
140
- max: 1024 * 8, // how many bytes of autoindex html to cache
140
+ maxSize: 1024 * 8, // how many bytes of autoindex html to cache
141
141
  maxAge: 1000 * 60 * 10, // how long to store it for
142
142
  },
143
143
 
144
144
  readdir: { // irrelevant if not using index:true
145
- max: 1000, // how many dir entries to cache
145
+ maxSize: 1000, // how many dir entries to cache
146
146
  maxAge: 1000 * 60 * 10, // how long to cache them for
147
147
  }
148
148
  },
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "st",
3
- "version": "3.0.2",
3
+ "version": "3.0.4",
4
4
  "description": "A module for serving static files. Does etags, caching, etc.",
5
5
  "main": "st.js",
6
6
  "bin": "bin/server.js",
7
7
  "dependencies": {
8
- "async-cache": "^1.1.0",
8
+ "lru-cache": "^11.1.0",
9
9
  "bl": "^6.1.0",
10
10
  "fd": "^0.0.3",
11
11
  "mime": "^3.0.0",
package/st.js CHANGED
@@ -9,7 +9,7 @@ try {
9
9
  const zlib = require('zlib')
10
10
  const Neg = require('negotiator')
11
11
  const http = require('http')
12
- const AC = require('async-cache')
12
+ const { LRUCache } = require('lru-cache')
13
13
  const FD = require('fd')
14
14
  const bl = require('bl')
15
15
  const { STATUS_CODES } = http
@@ -17,26 +17,31 @@ const { STATUS_CODES } = http
17
17
  const defaultCacheOptions = {
18
18
  fd: {
19
19
  max: 1000,
20
- maxAge: 1000 * 60 * 60
20
+ maxAge: 1000 * 60 * 60,
21
+ ignoreFetchAbort: true
21
22
  },
22
23
  stat: {
23
24
  max: 5000,
24
- maxAge: 1000 * 60
25
+ maxAge: 1000 * 60,
26
+ ignoreFetchAbort: true
25
27
  },
26
28
  content: {
27
- max: 1024 * 1024 * 64,
28
- length: (n) => n.length,
29
- maxAge: 1000 * 60 * 10
29
+ maxSize: 1024 * 1024 * 64,
30
+ sizeCalculation: (n) => n.length,
31
+ maxAge: 1000 * 60 * 10,
32
+ ignoreFetchAbort: true
30
33
  },
31
34
  index: {
32
- max: 1024 * 8,
33
- length: (n) => n.length,
34
- maxAge: 1000 * 60 * 10
35
+ maxSize: 1024 * 8,
36
+ sizeCalculation: (n) => n.length,
37
+ maxAge: 1000 * 60 * 10,
38
+ ignoreFetchAbort: true
35
39
  },
36
40
  readdir: {
37
- max: 1000,
38
- length: (n) => n.length,
39
- maxAge: 1000 * 60 * 10
41
+ maxSize: 1000,
42
+ sizeCalculation: (n) => Object.keys(n).length,
43
+ maxAge: 1000 * 60 * 10,
44
+ ignoreFetchAbort: true
40
45
  }
41
46
  }
42
47
 
@@ -44,8 +49,8 @@ const defaultCacheOptions = {
44
49
  // everything is really big. kind of a kludge, but easiest way
45
50
  // to get it done
46
51
  const none = {
47
- max: 1,
48
- length: () => Infinity
52
+ maxSize: 1,
53
+ sizeCalculation: () => Number.MAX_SAFE_INTEGER
49
54
  }
50
55
 
51
56
  const noCaching = {
@@ -56,6 +61,17 @@ const noCaching = {
56
61
  content: none
57
62
  }
58
63
 
64
+ const noCache = (fetch) => {
65
+ return {
66
+ maxSize: 0,
67
+ fetch,
68
+ has: () => false,
69
+ get: () => undefined,
70
+ set: () => {},
71
+ dump: () => []
72
+ }
73
+ }
74
+
59
75
  function st (opt) {
60
76
  let p, u
61
77
  if (typeof opt === 'string') {
@@ -124,11 +140,11 @@ class Mount {
124
140
  // cache basically everything
125
141
  const c = this.getCacheOptions(opt)
126
142
  this.cache = {
127
- fd: AC(c.fd),
128
- stat: AC(c.stat),
129
- index: AC(c.index),
130
- readdir: AC(c.readdir),
131
- content: AC(c.content)
143
+ fd: c.fd.noCache ? noCache(c.fd.fetchMethod) : new LRUCache(c.fd),
144
+ stat: c.stat.noCache ? noCache(c.stat.fetchMethod) : new LRUCache(c.stat),
145
+ index: c.index.noCache ? noCache(c.index.fetchMethod) : new LRUCache(c.index),
146
+ readdir: c.readdir.noCache ? noCache(c.readdir.fetchMethod) : new LRUCache(c.readdir),
147
+ content: c.content.noCache ? noCache(c.content.fetchMethod) : new LRUCache(c.content)
132
148
  }
133
149
 
134
150
  this._cacheControl =
@@ -145,7 +161,7 @@ class Mount {
145
161
  let o = opt.cache
146
162
  const set = (key) => {
147
163
  return o[key] === false
148
- ? Object.assign({}, none)
164
+ ? Object.assign({ noCache: true }, none)
149
165
  : Object.assign(Object.assign({}, d[key]), o[key])
150
166
  }
151
167
 
@@ -158,7 +174,7 @@ class Mount {
158
174
  const d = defaultCacheOptions
159
175
 
160
176
  // should really only ever set max and maxAge here.
161
- // load and fd disposal is important to control.
177
+ // fetchMethod and fd disposal is important to control.
162
178
  const c = {
163
179
  fd: set('fd'),
164
180
  stat: set('stat'),
@@ -167,13 +183,14 @@ class Mount {
167
183
  content: set('content')
168
184
  }
169
185
 
170
- c.fd.dispose = this.fdman.close.bind(this.fdman)
171
- c.fd.load = this.fdman.open.bind(this.fdman)
186
+ c.fd.dispose = (fd, key) => this.fdman.close(key, fd)
187
+ c.fd.fetchMethod = (key) => new Promise((resolve, reject) => this.fdman.open(key, (err, fd) => err ? reject(err) : resolve(fd)))
188
+
189
+ c.stat.fetchMethod = (key) => new Promise((resolve, reject) => this._loadStat(key, (err, fd) => err ? reject(err) : resolve(fd)))
190
+ c.index.fetchMethod = (key) => new Promise((resolve, reject) => this._loadIndex(key, (err, fd) => err ? reject(err) : resolve(fd)))
191
+ c.readdir.fetchMethod = (key) => new Promise((resolve, reject) => this._loadReaddir(key, (err, fd) => err ? reject(err) : resolve(fd)))
192
+ c.content.fetchMethod = (key) => new Promise((resolve, reject) => this._loadContent(key, (err, fd) => err ? reject(err) : resolve(fd)))
172
193
 
173
- c.stat.load = this._loadStat.bind(this)
174
- c.index.load = this._loadIndex.bind(this)
175
- c.readdir.load = this._loadReaddir.bind(this)
176
- c.content.load = this._loadContent.bind(this)
177
194
  return c
178
195
  }
179
196
 
@@ -181,28 +198,28 @@ class Mount {
181
198
  getUriPath (u) {
182
199
  let p = new URL(u, 'http://base').pathname
183
200
 
201
+ // Percent-decode before checking for `..` segments. The URL parser only
202
+ // resolves dot-segments that appear literally in the pathname, so an
203
+ // encoded separator (e.g. `/..%2f..%2fsecret`) keeps the `..` hidden and
204
+ // sails past the traversal check below until it is decoded. Decoding first
205
+ // means the check, and `path.normalize` after it, see the real path.
206
+ try {
207
+ p = decodeURIComponent(p)
208
+ } catch (e) {
209
+ // not a valid url-encoded path, so we can't safely serve it
210
+ return false
211
+ }
212
+
184
213
  // Convert any backslashes to forward slashes (for consistency)
185
214
  p = p.replace(/\\/g, '/')
186
215
 
187
- // Remove the redundant leading slash replacement - URL().pathname always starts with /
188
- if ((/[/\\]\.\.([/\\]|$)/).test(p)) {
216
+ if ((/(^|\/)\.\.(\/|$)/).test(p)) {
189
217
  return 403
190
218
  }
191
219
 
192
220
  u = path.normalize(p).replace(/\\/g, '/')
193
- if (u.indexOf(this.url) !== 0) {
194
- return false
195
- }
196
-
197
- // URL constructor already decoded, but we might need additional decoding
198
- // for edge cases. Only do it if it would actually change something.
199
- try {
200
- const decoded = decodeURIComponent(u)
201
- if (decoded !== u) {
202
- u = decoded
203
- }
204
- } catch (e) {
205
- // if decodeURIComponent failed, we weren't given a valid URL to begin with.
221
+ const prefix = this.url.endsWith('/') ? this.url : this.url + '/'
222
+ if (this.url !== '/' && u !== this.url && u.indexOf(prefix) !== 0) {
206
223
  return false
207
224
  }
208
225
 
@@ -210,16 +227,28 @@ class Mount {
210
227
  if (u.charAt(0) !== '/') {
211
228
  u = '/' + u
212
229
  }
230
+ if ((/(^|\/)\.\.(\/|$)/).test(u)) {
231
+ return 403
232
+ }
213
233
 
214
234
  return u
215
235
  }
216
236
 
217
237
  // get a path from a url
218
238
  getPath (u) {
219
- // trailing slash removal to fix Node.js v23 bug
220
- // https://github.com/nodejs/node/pull/55527
221
- // can be removed when this is resolved and released
222
- return path.join(this.path, u.replace(/\/+$/, ''))
239
+ // Normalize paths by removing trailing slashes
240
+ // This ensures consistent paths for directory content rendering
241
+ while (u.length > 0 && u[u.length - 1] === '/') {
242
+ u = u.slice(0, -1)
243
+ }
244
+
245
+ const p = path.resolve(this.path, '.' + u)
246
+ const rel = path.relative(this.path, p)
247
+ if (rel === '..' || rel.indexOf('..' + path.sep) === 0 || path.isAbsolute(rel)) {
248
+ return 403
249
+ }
250
+
251
+ return p
223
252
  }
224
253
 
225
254
  // get a url from a path
@@ -265,82 +294,89 @@ class Mount {
265
294
  }
266
295
 
267
296
  const p = this.getPath(req.sturl)
297
+ if (p === 403) {
298
+ res.statusCode = 403
299
+ res.end(STATUS_CODES[res.statusCode])
300
+ return true
301
+ }
268
302
 
269
303
  // now we have a path. check for the fd.
270
- this.cache.fd.get(p, (er, fd) => {
271
- // inability to open is some kind of error, probably 404
272
- // if we're in passthrough, AND got a next function, we can
273
- // fall through to that. otherwise, we already returned true,
274
- // send an error.
275
- if (er) {
304
+ this.cache.fd.fetch(p).then(
305
+ (fd) => {
306
+ // we may be about to use this, so don't let it be closed by cache purge
307
+ this.fdman.checkout(p, fd)
308
+ // a safe end() function that can be called multiple times but
309
+ // only perform a single checkin
310
+ const end = this.fdman.checkinfn(p, fd)
311
+
312
+ this.cache.stat.fetch(fd + ':' + p).then(
313
+ (stat) => {
314
+ const isDirectory = stat.isDirectory()
315
+
316
+ if (isDirectory) {
317
+ end() // we won't need this fd for a directory in any case
318
+ if (next && this.opt.passthrough === true && this._index === false) {
319
+ // this is done before if-modified-since and if-non-match checks so
320
+ // cached modified and etag values won't return 304's if we've since
321
+ // switched to !index. See Issue #51.
322
+ return next()
323
+ }
324
+ }
325
+
326
+ let ims = req.headers['if-modified-since']
327
+ if (ims) {
328
+ ims = new Date(ims).getTime()
329
+ }
330
+ if (ims && ims >= stat.mtime.getTime()) {
331
+ res.statusCode = 304
332
+ res.end()
333
+ return end()
334
+ }
335
+
336
+ const etag = getEtag(stat)
337
+ if (req.headers['if-none-match'] === etag) {
338
+ res.statusCode = 304
339
+ res.end()
340
+ return end()
341
+ }
342
+
343
+ // only set headers once we're sure we'll be serving this request
344
+ if (!res.getHeader('cache-control') && this._cacheControl) {
345
+ res.setHeader('cache-control', this._cacheControl)
346
+ }
347
+ res.setHeader('last-modified', stat.mtime.toUTCString())
348
+ res.setHeader('etag', etag)
349
+
350
+ if (this.opt.cors) {
351
+ res.setHeader('Access-Control-Allow-Origin', '*')
352
+ res.setHeader('Access-Control-Allow-Headers',
353
+ 'Origin, X-Requested-With, Content-Type, Accept, Range')
354
+ }
355
+
356
+ return isDirectory
357
+ ? this.index(p, req, res)
358
+ : this.file(p, fd, stat, etag, req, res, end)
359
+ },
360
+ (er) => {
361
+ if (next && this.opt.passthrough === true && this._index === false) {
362
+ return next()
363
+ }
364
+ end()
365
+ return this.error(er, res)
366
+ }
367
+ )
368
+ },
369
+ (er) => {
370
+ // inability to open is some kind of error, probably 404
371
+ // if we're in passthrough, AND got a next function, we can
372
+ // fall through to that. otherwise, we already returned true,
373
+ // send an error.
276
374
  if (this.opt.passthrough === true && er.code === 'ENOENT' && next) {
277
375
  return next()
278
376
  }
279
377
  return this.error(er, res)
280
378
  }
281
-
282
- // we may be about to use this, so don't let it be closed by cache purge
283
- this.fdman.checkout(p, fd)
284
- // a safe end() function that can be called multiple times but
285
- // only perform a single checkin
286
- const end = this.fdman.checkinfn(p, fd)
287
-
288
- this.cache.stat.get(fd + ':' + p, (er, stat) => {
289
- if (er) {
290
- if (next && this.opt.passthrough === true && this._index === false) {
291
- return next()
292
- }
293
- end()
294
- return this.error(er, res)
295
- }
296
-
297
- const isDirectory = stat.isDirectory()
298
-
299
- if (isDirectory) {
300
- end() // we won't need this fd for a directory in any case
301
- if (next && this.opt.passthrough === true && this._index === false) {
302
- // this is done before if-modified-since and if-non-match checks so
303
- // cached modified and etag values won't return 304's if we've since
304
- // switched to !index. See Issue #51.
305
- return next()
306
- }
307
- }
308
-
309
- let ims = req.headers['if-modified-since']
310
- if (ims) {
311
- ims = new Date(ims).getTime()
312
- }
313
- if (ims && ims >= stat.mtime.getTime()) {
314
- res.statusCode = 304
315
- res.end()
316
- return end()
317
- }
318
-
319
- const etag = getEtag(stat)
320
- if (req.headers['if-none-match'] === etag) {
321
- res.statusCode = 304
322
- res.end()
323
- return end()
324
- }
325
-
326
- // only set headers once we're sure we'll be serving this request
327
- if (!res.getHeader('cache-control') && this._cacheControl) {
328
- res.setHeader('cache-control', this._cacheControl)
329
- }
330
- res.setHeader('last-modified', stat.mtime.toUTCString())
331
- res.setHeader('etag', etag)
332
-
333
- if (this.opt.cors) {
334
- res.setHeader('Access-Control-Allow-Origin', '*')
335
- res.setHeader('Access-Control-Allow-Headers',
336
- 'Origin, X-Requested-With, Content-Type, Accept, Range')
337
- }
338
-
339
- return isDirectory
340
- ? this.index(p, req, res)
341
- : this.file(p, fd, stat, etag, req, res, end)
342
- })
343
- })
379
+ )
344
380
 
345
381
  return true
346
382
  }
@@ -385,16 +421,15 @@ class Mount {
385
421
  return
386
422
  }
387
423
 
388
- this.cache.index.get(p, (er, html) => {
389
- if (er) {
390
- return this.error(er, res)
391
- }
392
-
393
- res.statusCode = 200
394
- res.setHeader('content-type', 'text/html')
395
- res.setHeader('content-length', html.length)
396
- res.end(html)
397
- })
424
+ this.cache.index.fetch(p).then(
425
+ (html) => {
426
+ res.statusCode = 200
427
+ res.setHeader('content-type', 'text/html')
428
+ res.setHeader('content-length', html.length)
429
+ res.end(html)
430
+ },
431
+ (er) => this.error(er, res)
432
+ )
398
433
  }
399
434
 
400
435
  file (p, fd, stat, etag, req, res, end) {
@@ -418,23 +453,19 @@ class Mount {
418
453
  const key = stat.size + ':' + etag
419
454
  const gz = this.opt.gzip !== false && getGz(p, req)
420
455
 
421
- this.cache.content.get(key, (er, content) => {
422
- if (er) {
423
- return this.error(er, res)
424
- }
425
- res.statusCode = 200
426
- if (this.opt.cachedHeader) {
427
- res.setHeader('x-from-cache', 'true')
428
- }
429
- if (gz && content.gz) {
430
- res.setHeader('content-encoding', 'gzip')
431
- res.setHeader('content-length', content.gz.length)
432
- res.end(content.gz)
433
- } else {
434
- res.setHeader('content-length', content.length)
435
- res.end(content)
436
- }
437
- })
456
+ const content = this.cache.content.get(key)
457
+ res.statusCode = 200
458
+ if (this.opt.cachedHeader) {
459
+ res.setHeader('x-from-cache', 'true')
460
+ }
461
+ if (gz && content.gz) {
462
+ res.setHeader('content-encoding', 'gzip')
463
+ res.setHeader('content-length', content.gz.length)
464
+ res.end(content.gz)
465
+ } else {
466
+ res.setHeader('content-length', content.length)
467
+ res.end(content)
468
+ }
438
469
  }
439
470
 
440
471
  streamFile (p, fd, stat, etag, req, res, end) {
@@ -445,7 +476,7 @@ class Mount {
445
476
  // gzip only if not explicitly turned off or client doesn't accept it
446
477
  const gzOpt = this.opt.gzip !== false
447
478
  const gz = gzOpt && getGz(p, req)
448
- const cachable = this.cache.content._cache.max > stat.size
479
+ const cachable = this.cache.content.maxSize > stat.size
449
480
  let gzstr
450
481
 
451
482
  // need a gzipped version for the cache, so do it regardless of what the client wants
@@ -530,57 +561,56 @@ class Mount {
530
561
  '<h1>Index of ' + t + '</h1>' +
531
562
  '<hr><pre><a href="../">../</a>\n'
532
563
 
533
- this.cache.readdir.get(p, (er, data) => {
534
- if (er) {
535
- return cb(er)
536
- }
564
+ this.cache.readdir.fetch(p).then(
565
+ (data) => {
566
+ let nameLen = 0
567
+ let sizeLen = 0
537
568
 
538
- let nameLen = 0
539
- let sizeLen = 0
569
+ Object.keys(data).map((f) => {
570
+ const d = data[f]
540
571
 
541
- Object.keys(data).map((f) => {
542
- const d = data[f]
572
+ let name = f
573
+ .replace(/"/g, '&quot;')
574
+ .replace(/</g, '&lt;')
575
+ .replace(/>/g, '&gt;')
576
+ .replace(/'/g, '&#39;')
543
577
 
544
- let name = f
545
- .replace(/"/g, '&quot;')
546
- .replace(/</g, '&lt;')
547
- .replace(/>/g, '&gt;')
548
- .replace(/'/g, '&#39;')
549
-
550
- if (d.size === '-') {
551
- name += '/'
552
- }
553
- const showName = name.replace(/^(.{40}).{3,}$/, '$1..>')
554
- const linkName = encodeURIComponent(name)
555
- .replace(/%2e/ig, '.') // Encoded dots are dots
556
- .replace(/%2f|%5c/ig, '/') // encoded slashes are /
557
- .replace(/[/\\]/g, '/') // back slashes are slashes
558
-
559
- nameLen = Math.max(nameLen, showName.length)
560
- sizeLen = Math.max(sizeLen, ('' + d.size).length)
561
- return ['<a href="' + linkName + '">' + showName + '</a>',
562
- d.mtime, d.size, showName]
563
- }).sort((a, b) => {
564
- return a[2] === '-' && b[2] !== '-' // dirs first
565
- ? -1
566
- : a[2] !== '-' && b[2] === '-'
567
- ? 1
568
- : a[0].toLowerCase() < b[0].toLowerCase() // then alpha
569
- ? -1
570
- : a[0].toLowerCase() > b[0].toLowerCase()
571
- ? 1
572
- : 0
573
- }).forEach((line) => {
574
- const namePad = new Array(8 + nameLen - line[3].length).join(' ')
575
- const sizePad = new Array(8 + sizeLen - ('' + line[2]).length).join(' ')
576
- str += line[0] + namePad +
577
- line[1].toISOString() +
578
- sizePad + line[2] + '\n'
579
- })
578
+ if (d.size === '-') {
579
+ name += '/'
580
+ }
581
+ const showName = name.replace(/^(.{40}).{3,}$/, '$1..>')
582
+ const linkName = encodeURIComponent(name)
583
+ .replace(/%2e/ig, '.') // Encoded dots are dots
584
+ .replace(/%2f|%5c/ig, '/') // encoded slashes are /
585
+ .replace(/[/\\]/g, '/') // back slashes are slashes
586
+
587
+ nameLen = Math.max(nameLen, showName.length)
588
+ sizeLen = Math.max(sizeLen, ('' + d.size).length)
589
+ return ['<a href="' + linkName + '">' + showName + '</a>',
590
+ d.mtime, d.size, showName]
591
+ }).sort((a, b) => {
592
+ return a[2] === '-' && b[2] !== '-' // dirs first
593
+ ? -1
594
+ : a[2] !== '-' && b[2] === '-'
595
+ ? 1
596
+ : a[0].toLowerCase() < b[0].toLowerCase() // then alpha
597
+ ? -1
598
+ : a[0].toLowerCase() > b[0].toLowerCase()
599
+ ? 1
600
+ : 0
601
+ }).forEach((line) => {
602
+ const namePad = new Array(8 + nameLen - line[3].length).join(' ')
603
+ const sizePad = new Array(8 + sizeLen - ('' + line[2]).length).join(' ')
604
+ str += line[0] + namePad +
605
+ line[1].toISOString() +
606
+ sizePad + line[2] + '\n'
607
+ })
580
608
 
581
- str += '</pre><hr></body></html>'
582
- cb(null, Buffer.from(str))
583
- })
609
+ str += '</pre><hr></body></html>'
610
+ cb(null, Buffer.from(str))
611
+ },
612
+ (er) => cb(er)
613
+ )
584
614
  }
585
615
 
586
616
  _loadReaddir (p, cb) {
@@ -601,16 +631,16 @@ class Mount {
601
631
  data = {}
602
632
  files.forEach((file) => {
603
633
  const pf = path.join(p, file)
604
- this.cache.stat.get(pf, (er, stat) => {
605
- if (er) {
606
- return cb(er)
607
- }
608
- if (stat.isDirectory()) {
609
- stat.size = '-'
610
- }
611
- data[file] = stat
612
- next()
613
- })
634
+ this.cache.stat.fetch(pf).then(
635
+ (stat) => {
636
+ if (stat.isDirectory()) {
637
+ stat.size = '-'
638
+ }
639
+ data[file] = stat
640
+ next()
641
+ },
642
+ (er) => cb(er)
643
+ )
614
644
  })
615
645
  })
616
646
 
@@ -639,11 +669,11 @@ class Mount {
639
669
  }
640
670
  }
641
671
 
642
- _loadContent () {
672
+ _loadContent (_, cb) {
643
673
  // this function should never be called.
644
674
  // we check if the thing is in the cache, and if not, stream it in
645
- // manually. this.cache.content.get() should not ever happen.
646
- throw new Error('This should not ever happen')
675
+ // manually. this.cache.content.fetch() should not ever happen.
676
+ return cb(new Error('This should never happen'))
647
677
  }
648
678
  }
649
679
 
@@ -10,6 +10,7 @@ let server
10
10
 
11
11
  const opts = {
12
12
  dot: global.dot,
13
+ url: global.url,
13
14
  path: path.join(__dirname, 'fixtures', '.dotted-dir')
14
15
  }
15
16
 
@@ -0,0 +1,39 @@
1
+ global.dot = true
2
+
3
+ const path = require('path')
4
+ const { test } = require('tap')
5
+ const { req } = require('./dot-common')
6
+ const st = require('../st.js')
7
+
8
+ // A percent-encoded separator must not smuggle a `..` past the traversal
9
+ // guard. `..%2f` (and `..%5c`) only decode to `../` after the path has been
10
+ // checked, so the check has to run on the decoded path. `dot: true` is needed
11
+ // to reach this: with the default `dot: false`, the decoded `..` is rejected
12
+ // independently as a dot-url. `space in filename.txt` lives in the parent of
13
+ // the served root, so a non-403 here would mean the response left the mount.
14
+
15
+ test('encoded-slash parent traversal is forbidden', (t) => {
16
+ req('/..%2fspace%20in%20filename.txt', (er, res, body) => {
17
+ t.error(er)
18
+ t.equal(res.statusCode, 403)
19
+ t.end()
20
+ })
21
+ })
22
+
23
+ test('encoded-backslash parent traversal is forbidden', (t) => {
24
+ req('/..%5cspace%20in%20filename.txt', (er, res, body) => {
25
+ t.error(er)
26
+ t.equal(res.statusCode, 403)
27
+ t.end()
28
+ })
29
+ })
30
+
31
+ test('resolved paths cannot leave the root', (t) => {
32
+ const mount = st({
33
+ dot: true,
34
+ path: path.join(__dirname, 'fixtures', '.dotted-dir')
35
+ })._this
36
+
37
+ t.equal(mount.getPath('/../space in filename.txt'), 403)
38
+ t.end()
39
+ })
@@ -0,0 +1,46 @@
1
+ global.dot = true
2
+ global.url = '/static'
3
+
4
+ const { test } = require('tap')
5
+ const { req } = require('./dot-common')
6
+
7
+ // Non-root mounts need a segment-boundary check before stripping the mount
8
+ // prefix. Otherwise `/static..%2fsecret` can pass validation as
9
+ // `/static../secret`, then become `/../secret` after the `/static` prefix is
10
+ // removed.
11
+
12
+ test('mounted encoded-slash prefix traversal is not a match', (t) => {
13
+ req('/static..%2fspace%20in%20filename.txt', (er, res, body) => {
14
+ t.error(er)
15
+ t.equal(res.statusCode, 404)
16
+ t.notMatch(body, /space in filename/)
17
+ t.end()
18
+ })
19
+ })
20
+
21
+ test('mounted encoded-dot prefix traversal is not a match', (t) => {
22
+ req('/static%2e%2e%2fspace%20in%20filename.txt', (er, res, body) => {
23
+ t.error(er)
24
+ t.equal(res.statusCode, 404)
25
+ t.notMatch(body, /space in filename/)
26
+ t.end()
27
+ })
28
+ })
29
+
30
+ test('mounted encoded-backslash prefix traversal is not a match', (t) => {
31
+ req('/static..%5cspace%20in%20filename.txt', (er, res, body) => {
32
+ t.error(er)
33
+ t.equal(res.statusCode, 404)
34
+ t.notMatch(body, /space in filename/)
35
+ t.end()
36
+ })
37
+ })
38
+
39
+ test('mounted encoded traversal inside the mount is forbidden', (t) => {
40
+ req('/static/..%2fspace%20in%20filename.txt', (er, res, body) => {
41
+ t.error(er)
42
+ t.equal(res.statusCode, 403)
43
+ t.notMatch(body, /space in filename/)
44
+ t.end()
45
+ })
46
+ })
package/test/no-cache.js CHANGED
@@ -10,10 +10,10 @@ const { test } = require('tap')
10
10
  // additional tests to ensure that it's actually not caching.
11
11
 
12
12
  test('all caches should be empty', (t) => {
13
- t.same(mount._this.cache.fd._cache.dump(), [])
14
- t.same(mount._this.cache.stat._cache.dump(), [])
15
- t.same(mount._this.cache.index._cache.dump(), [])
16
- t.same(mount._this.cache.readdir._cache.dump(), [])
17
- t.same(mount._this.cache.content._cache.dump(), [])
13
+ t.same(mount._this.cache.fd.dump(), [])
14
+ t.same(mount._this.cache.stat.dump(), [])
15
+ t.same(mount._this.cache.index.dump(), [])
16
+ t.same(mount._this.cache.readdir.dump(), [])
17
+ t.same(mount._this.cache.content.dump(), [])
18
18
  t.end()
19
19
  })