st 3.0.3 → 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/package.json +1 -1
- package/st.js +49 -23
- package/test/dot-common.js +1 -0
- package/test/encoded-traversal.js +39 -0
- package/test/mounted-encoded-traversal.js +46 -0
- package/.claude/settings.local.json +0 -12
|
@@ -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/package.json
CHANGED
package/st.js
CHANGED
|
@@ -61,6 +61,17 @@ const noCaching = {
|
|
|
61
61
|
content: none
|
|
62
62
|
}
|
|
63
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
|
+
|
|
64
75
|
function st (opt) {
|
|
65
76
|
let p, u
|
|
66
77
|
if (typeof opt === 'string') {
|
|
@@ -129,11 +140,11 @@ class Mount {
|
|
|
129
140
|
// cache basically everything
|
|
130
141
|
const c = this.getCacheOptions(opt)
|
|
131
142
|
this.cache = {
|
|
132
|
-
fd: new LRUCache(c.fd),
|
|
133
|
-
stat: new LRUCache(c.stat),
|
|
134
|
-
index: new LRUCache(c.index),
|
|
135
|
-
readdir: new LRUCache(c.readdir),
|
|
136
|
-
content: new LRUCache(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)
|
|
137
148
|
}
|
|
138
149
|
|
|
139
150
|
this._cacheControl =
|
|
@@ -150,7 +161,7 @@ class Mount {
|
|
|
150
161
|
let o = opt.cache
|
|
151
162
|
const set = (key) => {
|
|
152
163
|
return o[key] === false
|
|
153
|
-
? Object.assign({}, none)
|
|
164
|
+
? Object.assign({ noCache: true }, none)
|
|
154
165
|
: Object.assign(Object.assign({}, d[key]), o[key])
|
|
155
166
|
}
|
|
156
167
|
|
|
@@ -172,7 +183,7 @@ class Mount {
|
|
|
172
183
|
content: set('content')
|
|
173
184
|
}
|
|
174
185
|
|
|
175
|
-
c.fd.dispose = (key) => this.fdman.close(key)
|
|
186
|
+
c.fd.dispose = (fd, key) => this.fdman.close(key, fd)
|
|
176
187
|
c.fd.fetchMethod = (key) => new Promise((resolve, reject) => this.fdman.open(key, (err, fd) => err ? reject(err) : resolve(fd)))
|
|
177
188
|
|
|
178
189
|
c.stat.fetchMethod = (key) => new Promise((resolve, reject) => this._loadStat(key, (err, fd) => err ? reject(err) : resolve(fd)))
|
|
@@ -187,28 +198,28 @@ class Mount {
|
|
|
187
198
|
getUriPath (u) {
|
|
188
199
|
let p = new URL(u, 'http://base').pathname
|
|
189
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
|
+
|
|
190
213
|
// Convert any backslashes to forward slashes (for consistency)
|
|
191
214
|
p = p.replace(/\\/g, '/')
|
|
192
215
|
|
|
193
|
-
|
|
194
|
-
if ((/[/\\]\.\.([/\\]|$)/).test(p)) {
|
|
216
|
+
if ((/(^|\/)\.\.(\/|$)/).test(p)) {
|
|
195
217
|
return 403
|
|
196
218
|
}
|
|
197
219
|
|
|
198
220
|
u = path.normalize(p).replace(/\\/g, '/')
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// URL constructor already decoded, but we might need additional decoding
|
|
204
|
-
// for edge cases. Only do it if it would actually change something.
|
|
205
|
-
try {
|
|
206
|
-
const decoded = decodeURIComponent(u)
|
|
207
|
-
if (decoded !== u) {
|
|
208
|
-
u = decoded
|
|
209
|
-
}
|
|
210
|
-
} catch (e) {
|
|
211
|
-
// 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) {
|
|
212
223
|
return false
|
|
213
224
|
}
|
|
214
225
|
|
|
@@ -216,6 +227,9 @@ class Mount {
|
|
|
216
227
|
if (u.charAt(0) !== '/') {
|
|
217
228
|
u = '/' + u
|
|
218
229
|
}
|
|
230
|
+
if ((/(^|\/)\.\.(\/|$)/).test(u)) {
|
|
231
|
+
return 403
|
|
232
|
+
}
|
|
219
233
|
|
|
220
234
|
return u
|
|
221
235
|
}
|
|
@@ -227,7 +241,14 @@ class Mount {
|
|
|
227
241
|
while (u.length > 0 && u[u.length - 1] === '/') {
|
|
228
242
|
u = u.slice(0, -1)
|
|
229
243
|
}
|
|
230
|
-
|
|
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
|
|
231
252
|
}
|
|
232
253
|
|
|
233
254
|
// get a url from a path
|
|
@@ -273,6 +294,11 @@ class Mount {
|
|
|
273
294
|
}
|
|
274
295
|
|
|
275
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
|
+
}
|
|
276
302
|
|
|
277
303
|
// now we have a path. check for the fd.
|
|
278
304
|
this.cache.fd.fetch(p).then(
|
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
|
+
})
|