@fastify/static 9.1.0 → 9.1.2

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.
@@ -0,0 +1,19 @@
1
+ name: Lock Threads
2
+
3
+ on:
4
+ schedule:
5
+ - cron: '0 0 1 * *'
6
+ workflow_dispatch:
7
+
8
+ concurrency:
9
+ group: lock
10
+
11
+ permissions:
12
+ contents: read
13
+
14
+ jobs:
15
+ lock-threads:
16
+ permissions:
17
+ issues: write
18
+ pull-requests: write
19
+ uses: fastify/workflows/.github/workflows/lock-threads.yml@v6
package/index.js CHANGED
@@ -122,7 +122,12 @@ async function fastifyStatic (fastify, opts) {
122
122
  method: ['HEAD', 'GET'],
123
123
  path: prefix + '*',
124
124
  handler (req, reply) {
125
- pumpSendToReply(req, reply, '/' + req.params['*'], sendOptions.root)
125
+ const pathname = getPathnameForSend(req.raw.url, req.routeOptions.url)
126
+ if (!pathname) {
127
+ return reply.callNotFound()
128
+ }
129
+
130
+ pumpSendToReply(req, reply, pathname, sendOptions.root)
126
131
  }
127
132
  })
128
133
  if (opts.redirect === true && prefix !== opts.prefix) {
@@ -176,6 +181,29 @@ async function fastifyStatic (fastify, opts) {
176
181
 
177
182
  const allowedPath = opts.allowedPath
178
183
 
184
+ /**
185
+ * @param {import("fastify").FastifyReply} reply
186
+ * @param {string} pathname
187
+ * @return {Promise<boolean>}
188
+ */
189
+ async function sendDirectoryList (reply, pathname) {
190
+ const dir = dirList.path(opts.root, pathname)
191
+ if (!dir) {
192
+ return false
193
+ }
194
+
195
+ await dirList.send({
196
+ reply,
197
+ dir,
198
+ options: opts.list,
199
+ route: pathname,
200
+ prefix,
201
+ dotfiles: opts.dotfiles
202
+ }).catch((err) => reply.send(err))
203
+
204
+ return true
205
+ }
206
+
179
207
  /**
180
208
  * @param {import("fastify").FastifyRequest} request
181
209
  * @param {import("fastify").FastifyReply} reply
@@ -289,15 +317,7 @@ async function fastifyStatic (fastify, opts) {
289
317
  (!options.index || !options.index.length) &&
290
318
  pathnameForSend[pathnameForSend.length - 1] === '/'
291
319
  ) {
292
- if (opts.list) {
293
- await dirList.send({
294
- reply,
295
- dir: dirList.path(opts.root, pathname),
296
- options: opts.list,
297
- route: pathname,
298
- prefix,
299
- dotfiles: opts.dotfiles
300
- }).catch((err) => reply.send(err))
320
+ if (opts.list && await sendDirectoryList(reply, pathname)) {
301
321
  return
302
322
  }
303
323
  }
@@ -322,15 +342,7 @@ async function fastifyStatic (fastify, opts) {
322
342
  }
323
343
 
324
344
  // if file exists, send real file, otherwise send dir list if name match
325
- if (opts.list && dirList.handle(pathname, opts.list)) {
326
- await dirList.send({
327
- reply,
328
- dir: dirList.path(opts.root, pathname),
329
- options: opts.list,
330
- route: pathname,
331
- prefix,
332
- dotfiles: opts.dotfiles
333
- }).catch((err) => reply.send(err))
345
+ if (opts.list && dirList.handle(pathname, opts.list) && await sendDirectoryList(reply, pathname)) {
334
346
  return
335
347
  }
336
348
 
@@ -556,6 +568,38 @@ function getEncodingHeader (headers, checked) {
556
568
  )
557
569
  }
558
570
 
571
+ /**
572
+ * @param {string} url
573
+ * @param {string} route
574
+ * @returns {string|undefined}
575
+ */
576
+ function getPathnameForSend (url, route) {
577
+ const questionMark = url.indexOf('?')
578
+ let pathname = questionMark === -1 ? url : url.slice(0, questionMark)
579
+
580
+ const routePrefix = route.endsWith('*')
581
+ ? route.slice(0, -1)
582
+ : route
583
+
584
+ if (routePrefix !== '/' && !pathname.startsWith(routePrefix)) {
585
+ return
586
+ }
587
+
588
+ pathname = pathname.slice(routePrefix.length)
589
+
590
+ if (pathname === '') {
591
+ pathname = '/'
592
+ } else if (!pathname.startsWith('/')) {
593
+ pathname = '/' + pathname
594
+ }
595
+
596
+ try {
597
+ return decodeURI(pathname)
598
+ } catch {
599
+
600
+ }
601
+ }
602
+
559
603
  /**
560
604
  * @param {string} url
561
605
  * @return {string}
package/lib/dirList.js CHANGED
@@ -6,6 +6,16 @@ const fs = require('node:fs/promises')
6
6
  const fastq = require('fastq')
7
7
  const fastqConcurrency = Math.max(1, os.cpus().length - 1)
8
8
 
9
+ function isPathInsideRoot (root, target) {
10
+ const relativePath = path.relative(root, target)
11
+
12
+ return relativePath === '' || (
13
+ relativePath !== '..' &&
14
+ !relativePath.startsWith(`..${path.sep}`) &&
15
+ !path.isAbsolute(relativePath)
16
+ )
17
+ }
18
+
9
19
  const dirList = {
10
20
  _getExtendedInfo: async function (dir, info) {
11
21
  const depth = dir.split(path.sep).length
@@ -146,10 +156,10 @@ const dirList = {
146
156
  */
147
157
  htmlInfo: function (entry, route, prefix, options) {
148
158
  if (options.names?.includes(path.basename(route))) {
149
- route = path.normalize(path.join(route, '..'))
159
+ route = path.posix.normalize(path.posix.join(route, '..'))
150
160
  }
151
161
  return {
152
- href: encodeURI(path.join(prefix, route, entry.name).replace(/\\/gu, '/')),
162
+ href: encodeURI(path.posix.join(prefix, route, entry.name)),
153
163
  name: entry.name,
154
164
  stats: entry.stats,
155
165
  extendedInfo: entry.extendedInfo
@@ -172,12 +182,16 @@ const dirList = {
172
182
  * get path from route and fs root paths, considering trailing slash
173
183
  * @param {string} root fs root path
174
184
  * @param {string} route request route
185
+ * @return {string|null}
175
186
  */
176
187
  path: function (root, route) {
177
- const _route = route[route.length - 1] === '/'
178
- ? route + 'none'
179
- : route
180
- return path.dirname(path.join(root, _route))
188
+ const relativeRoute = route[route.length - 1] === '/'
189
+ ? '.' + route + 'none'
190
+ : '.' + route
191
+ const resolvedRoot = path.resolve(root)
192
+ const dir = path.dirname(path.resolve(resolvedRoot, relativeRoute))
193
+
194
+ return isPathInsideRoot(resolvedRoot, dir) ? dir : null
181
195
  },
182
196
 
183
197
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastify/static",
3
- "version": "9.1.0",
3
+ "version": "9.1.2",
4
4
  "description": "Plugin for serving static files as fast as possible.",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
@@ -3,6 +3,7 @@
3
3
  /* eslint n/no-deprecated-api: "off" */
4
4
 
5
5
  const fs = require('node:fs')
6
+ const http = require('node:http')
6
7
  const path = require('node:path')
7
8
  const { test } = require('node:test')
8
9
  const Fastify = require('fastify')
@@ -18,6 +19,31 @@ const helper = {
18
19
  await fastify.listen({ port: 0 })
19
20
  fastify.server.unref()
20
21
  await f('http://localhost:' + fastify.server.address().port)
22
+ },
23
+ rawRequest: async function (baseUrl, route) {
24
+ const url = new URL(baseUrl)
25
+
26
+ return await new Promise((resolve, reject) => {
27
+ const request = http.request({
28
+ hostname: url.hostname,
29
+ port: url.port,
30
+ path: route,
31
+ method: 'GET'
32
+ }, (response) => {
33
+ let body = ''
34
+ response.setEncoding('utf8')
35
+ response.on('data', chunk => { body += chunk })
36
+ response.on('end', () => {
37
+ resolve({
38
+ statusCode: response.statusCode,
39
+ body
40
+ })
41
+ })
42
+ })
43
+
44
+ request.on('error', reject)
45
+ request.end()
46
+ })
21
47
  }
22
48
  }
23
49
 
@@ -65,6 +91,16 @@ test('throws when `list.format` is html and `list render` is not a function', t
65
91
  t.assert.deepStrictEqual(err.message, 'The `list.render` option must be a function and is required with html format')
66
92
  })
67
93
 
94
+ test('dir list path stays within the configured root', t => {
95
+ t.plan(3)
96
+
97
+ const root = path.join(__dirname, '/static')
98
+
99
+ t.assert.deepStrictEqual(dirList.path(root, '/'), root)
100
+ t.assert.deepStrictEqual(dirList.path(root, '/deep/path/'), path.join(root, 'deep/path'))
101
+ t.assert.deepStrictEqual(dirList.path(root, '/../'), null)
102
+ })
103
+
68
104
  test('dir list wrong options', async t => {
69
105
  t.plan(3)
70
106
 
@@ -181,6 +217,52 @@ test('dir list, custom options with empty array index', async t => {
181
217
  })
182
218
  })
183
219
 
220
+ test('dir list blocks path traversal when index is false', async t => {
221
+ t.plan(2)
222
+
223
+ const options = {
224
+ root: path.join(__dirname, '/static'),
225
+ prefix: '/public',
226
+ index: false,
227
+ list: true
228
+ }
229
+ const routes = ['/public/../', '/public/../static2/']
230
+
231
+ await helper.arrange(t, options, async (url) => {
232
+ for (const route of routes) {
233
+ await t.test(route, async t => {
234
+ t.plan(2)
235
+
236
+ const response = await helper.rawRequest(url, route)
237
+ t.assert.deepStrictEqual(response.statusCode, 403)
238
+ t.assert.match(response.body, /Forbidden/u)
239
+ })
240
+ }
241
+ })
242
+ })
243
+
244
+ test('dir list blocks path traversal when index is an empty array', async t => {
245
+ t.plan(1)
246
+
247
+ const options = {
248
+ root: path.join(__dirname, '/static'),
249
+ prefix: '/public',
250
+ index: [],
251
+ list: true
252
+ }
253
+ const route = '/public/../'
254
+
255
+ await helper.arrange(t, options, async (url) => {
256
+ await t.test(route, async t => {
257
+ t.plan(2)
258
+
259
+ const response = await helper.rawRequest(url, route)
260
+ t.assert.deepStrictEqual(response.statusCode, 403)
261
+ t.assert.match(response.body, /Forbidden/u)
262
+ })
263
+ })
264
+ })
265
+
184
266
  test('dir list html format', async t => {
185
267
  t.plan(2)
186
268
 
@@ -6,6 +6,7 @@ const path = require('node:path')
6
6
  const fs = require('node:fs')
7
7
  const url = require('node:url')
8
8
  const http = require('node:http')
9
+ const Module = require('node:module')
9
10
  const { test } = require('node:test')
10
11
  const Fastify = require('fastify')
11
12
  const compress = require('@fastify/compress')
@@ -81,6 +82,16 @@ if (typeof Promise.withResolvers === 'undefined') {
81
82
  }
82
83
  }
83
84
 
85
+ function loadInternalPathnameForSend () {
86
+ const modulePath = require.resolve('../index.js')
87
+ const source = fs.readFileSync(modulePath, 'utf8') + '\nmodule.exports.__getPathnameForSend = getPathnameForSend\n'
88
+ const testModule = new Module(modulePath)
89
+ testModule.filename = modulePath
90
+ testModule.paths = module.paths
91
+ testModule._compile(source, modulePath)
92
+ return testModule.exports.__getPathnameForSend
93
+ }
94
+
84
95
  test('register /static prefixAvoidTrailingSlash', async t => {
85
96
  t.plan(11)
86
97
 
@@ -3254,8 +3265,8 @@ test('should not redirect to protocol-relative locations', async (t) => {
3254
3265
  ['//^/..', '/', 301],
3255
3266
  ['//^/.', null, 404], // it is NOT recognized as a directory by pillarjs/send
3256
3267
  ['//:/..', '/', 301],
3257
- ['/\\\\a//google.com/%2e%2e%2f%2e%2e', '/a//google.com/%2e%2e%2f%2e%2e/', 301],
3258
- ['//a//youtube.com/%2e%2e%2f%2e%2e', '/a//youtube.com/%2e%2e%2f%2e%2e/', 301],
3268
+ ['/\\\\a//google.com/%2e%2e%2f%2e%2e', null, 404],
3269
+ ['//a//youtube.com/%2e%2e%2f%2e%2e', null, 404],
3259
3270
  ['/^', null, 404], // it is NOT recognized as a directory by pillarjs/send
3260
3271
  ['//google.com/%2e%2e', '/', 301],
3261
3272
  ['//users/%2e%2e', '/', 301],
@@ -3590,6 +3601,106 @@ test(
3590
3601
  }
3591
3602
  )
3592
3603
 
3604
+ test('getPathnameForSend returns undefined for mismatched and malformed routes', (t) => {
3605
+ t.plan(3)
3606
+
3607
+ const getPathnameForSend = loadInternalPathnameForSend()
3608
+
3609
+ t.assert.deepStrictEqual(getPathnameForSend('/nested/public/index.css', '/public/*'), undefined)
3610
+ t.assert.deepStrictEqual(getPathnameForSend('/static/%E0%A4%A', '/static/*'), undefined)
3611
+ t.assert.deepStrictEqual(getPathnameForSend('/static/index.css', '/static'), '/index.css')
3612
+ })
3613
+
3614
+ test('wildcard handler falls back to not found when the raw url does not match the route prefix', async (t) => {
3615
+ t.plan(2)
3616
+
3617
+ const fastify = Fastify()
3618
+ let wildcardHandler
3619
+
3620
+ fastify.addHook('onRoute', (route) => {
3621
+ if (route.url === '/static/*') {
3622
+ wildcardHandler = route.handler
3623
+ }
3624
+ })
3625
+
3626
+ fastify.register(fastifyStatic, {
3627
+ root: path.join(__dirname, '/static'),
3628
+ prefix: '/static'
3629
+ })
3630
+
3631
+ t.after(() => fastify.close())
3632
+
3633
+ await fastify.ready()
3634
+ t.assert.ok(wildcardHandler)
3635
+
3636
+ let notFoundCalled = false
3637
+ wildcardHandler({
3638
+ raw: { url: '/nested/static/index.css' },
3639
+ routeOptions: { url: '/static/*' }
3640
+ }, {
3641
+ callNotFound () {
3642
+ notFoundCalled = true
3643
+ }
3644
+ })
3645
+
3646
+ t.assert.deepStrictEqual(notFoundCalled, true)
3647
+ })
3648
+
3649
+ test('does not serve static files with encoded path separators', async (t) => {
3650
+ t.plan(4)
3651
+
3652
+ const fastify = Fastify()
3653
+
3654
+ t.after(() => fastify.close())
3655
+
3656
+ fastify.all('/deep/*', async (_request, reply) => {
3657
+ reply.code(401).send('Unauthorized')
3658
+ })
3659
+
3660
+ fastify.register(fastifyStatic, {
3661
+ root: path.join(__dirname, '/static')
3662
+ })
3663
+
3664
+ const response = await fastify.inject({
3665
+ method: 'GET',
3666
+ url: '/deep/path/for/test/purpose/foo.html'
3667
+ })
3668
+ t.assert.deepStrictEqual(response.statusCode, 401)
3669
+ t.assert.deepStrictEqual(response.body, 'Unauthorized')
3670
+
3671
+ const encodedPathResponse = await fastify.inject({
3672
+ method: 'GET',
3673
+ url: '/deep%2Fpath/for/test/purpose/foo.html'
3674
+ })
3675
+ t.assert.deepStrictEqual(encodedPathResponse.statusCode, 404)
3676
+ t.assert.deepStrictEqual(encodedPathResponse.json().message, 'Route GET:/deep%2Fpath/for/test/purpose/foo.html not found')
3677
+ })
3678
+
3679
+ test('serves wildcard files when registered in an encapsulated context', async (t) => {
3680
+ t.plan(3)
3681
+
3682
+ const fastify = Fastify()
3683
+
3684
+ t.after(() => fastify.close())
3685
+
3686
+ fastify.register(async function (childContext) {
3687
+ childContext.register(fastifyStatic, {
3688
+ root: path.join(__dirname, '/static'),
3689
+ prefix: '/public',
3690
+ decorateReply: false
3691
+ })
3692
+ }, { prefix: '/nested' })
3693
+
3694
+ const response = await fastify.inject({
3695
+ method: 'GET',
3696
+ url: '/nested/public/index.css'
3697
+ })
3698
+
3699
+ t.assert.deepStrictEqual(response.statusCode, 200)
3700
+ t.assert.deepStrictEqual(response.headers['content-type'], 'text/css; charset=utf-8')
3701
+ t.assert.deepStrictEqual(response.body, fs.readFileSync(path.join(__dirname, '/static/index.css'), 'utf8'))
3702
+ })
3703
+
3593
3704
  test('content-length in head route should not return zero when using wildcard', async t => {
3594
3705
  t.plan(5)
3595
3706