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.
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "st",
3
- "version": "3.0.3",
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",
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
- // Remove the redundant leading slash replacement - URL().pathname always starts with /
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
- if (u.indexOf(this.url) !== 0) {
200
- return false
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
- return path.join(this.path, u)
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(
@@ -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
+ })
@@ -1,12 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "WebFetch(domain:github.com)",
5
- "WebFetch(domain:patch-diff.githubusercontent.com)",
6
- "Bash(curl:*)",
7
- "Bash(node:*)",
8
- "Bash(npm test:*)"
9
- ],
10
- "deny": []
11
- }
12
- }