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.
- package/.github/workflows/test.yml +2 -2
- package/README.md +3 -3
- package/package.json +2 -2
- package/st.js +233 -203
- package/test/dot-common.js +1 -0
- package/test/encoded-traversal.js +39 -0
- package/test/mounted-encoded-traversal.js +46 -0
- package/test/no-cache.js +5 -5
|
@@ -10,9 +10,9 @@ jobs:
|
|
|
10
10
|
runs-on: ${{ matrix.os }}
|
|
11
11
|
steps:
|
|
12
12
|
- name: Checkout Repository
|
|
13
|
-
uses: actions/checkout@
|
|
13
|
+
uses: actions/checkout@v7
|
|
14
14
|
- name: Use Node.js ${{ matrix.node }}
|
|
15
|
-
uses: actions/setup-node@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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:
|
|
128
|
-
stat:
|
|
129
|
-
index:
|
|
130
|
-
readdir:
|
|
131
|
-
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
|
-
//
|
|
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
|
|
171
|
-
c.fd.
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
//
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
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.
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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.
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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.
|
|
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.
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
564
|
+
this.cache.readdir.fetch(p).then(
|
|
565
|
+
(data) => {
|
|
566
|
+
let nameLen = 0
|
|
567
|
+
let sizeLen = 0
|
|
537
568
|
|
|
538
|
-
|
|
539
|
-
|
|
569
|
+
Object.keys(data).map((f) => {
|
|
570
|
+
const d = data[f]
|
|
540
571
|
|
|
541
|
-
|
|
542
|
-
|
|
572
|
+
let name = f
|
|
573
|
+
.replace(/"/g, '"')
|
|
574
|
+
.replace(/</g, '<')
|
|
575
|
+
.replace(/>/g, '>')
|
|
576
|
+
.replace(/'/g, ''')
|
|
543
577
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
.replace(
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
582
|
-
|
|
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.
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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.
|
|
646
|
-
|
|
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
|
|
package/test/dot-common.js
CHANGED
|
@@ -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.
|
|
14
|
-
t.same(mount._this.cache.stat.
|
|
15
|
-
t.same(mount._this.cache.index.
|
|
16
|
-
t.same(mount._this.cache.readdir.
|
|
17
|
-
t.same(mount._this.cache.content.
|
|
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
|
})
|