@fastify/static 9.0.0 → 9.1.1

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.
@@ -27,7 +27,7 @@ jobs:
27
27
  permissions:
28
28
  contents: write
29
29
  pull-requests: write
30
- uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5
30
+ uses: fastify/workflows/.github/workflows/plugins-ci.yml@v6
31
31
  with:
32
32
  license-check: true
33
33
  lint: true
@@ -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/LICENSE CHANGED
@@ -1,8 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2017-present The Fastify team
4
-
5
- The Fastify team members are listed at https://github.com/fastify/fastify#team.
3
+ Copyright (c) 2017-present The Fastify team <https://github.com/fastify/fastify#team>
6
4
 
7
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
8
6
  of this software and associated documentation files (the "Software"), to deal
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, prefix)
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
@@ -274,7 +302,7 @@ async function fastifyStatic (fastify, opts) {
274
302
  pathname + '/',
275
303
  rootPath,
276
304
  undefined,
277
- undefined,
305
+ pumpOptions,
278
306
  checkedEncodings
279
307
  )
280
308
  }
@@ -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
  }
@@ -314,7 +334,7 @@ async function fastifyStatic (fastify, opts) {
314
334
  pathname + '/',
315
335
  rootPath,
316
336
  undefined,
317
- undefined,
337
+ pumpOptions,
318
338
  checkedEncodings
319
339
  )
320
340
  }
@@ -322,21 +342,21 @@ 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
 
337
349
  // root paths left to try?
338
350
  if (Array.isArray(rootPath) && rootPathOffset < (rootPath.length - 1)) {
339
- return pumpSendToReply(request, reply, pathname, rootPath, rootPathOffset + 1)
351
+ return pumpSendToReply(
352
+ request,
353
+ reply,
354
+ pathname,
355
+ rootPath,
356
+ rootPathOffset + 1,
357
+ pumpOptions,
358
+ undefined
359
+ )
340
360
  }
341
361
 
342
362
  if (opts.preCompressed && !checkedEncodings.has(encoding)) {
@@ -347,7 +367,7 @@ async function fastifyStatic (fastify, opts) {
347
367
  pathnameOrig,
348
368
  rootPath,
349
369
  rootPathOffset,
350
- undefined,
370
+ pumpOptions,
351
371
  checkedEncodings
352
372
  )
353
373
  }
@@ -548,6 +568,40 @@ function getEncodingHeader (headers, checked) {
548
568
  )
549
569
  }
550
570
 
571
+ /**
572
+ * @param {string} url
573
+ * @param {string} prefix
574
+ * @returns {string|undefined}
575
+ */
576
+ function getPathnameForSend (url, prefix) {
577
+ const questionMark = url.indexOf('?')
578
+ let pathname = questionMark === -1 ? url : url.slice(0, questionMark)
579
+
580
+ if (prefix !== '/') {
581
+ const routePrefix = prefix.endsWith('/')
582
+ ? prefix.slice(0, -1)
583
+ : prefix
584
+
585
+ if (!pathname.startsWith(routePrefix)) {
586
+ return
587
+ }
588
+
589
+ pathname = pathname.slice(routePrefix.length)
590
+ }
591
+
592
+ if (pathname === '') {
593
+ pathname = '/'
594
+ } else if (!pathname.startsWith('/')) {
595
+ pathname = '/' + pathname
596
+ }
597
+
598
+ try {
599
+ return decodeURI(pathname)
600
+ } catch {
601
+
602
+ }
603
+ }
604
+
551
605
  /**
552
606
  * @param {string} url
553
607
  * @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.0.0",
3
+ "version": "9.1.1",
4
4
  "description": "Plugin for serving static files as fast as possible.",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
@@ -67,14 +67,14 @@
67
67
  },
68
68
  "devDependencies": {
69
69
  "@fastify/compress": "^8.0.0",
70
- "@types/node": "^24.0.13",
71
- "borp": "^0.20.0",
72
- "c8": "^10.1.3",
70
+ "@types/node": "^25.0.3",
71
+ "borp": "^1.0.0",
72
+ "c8": "^11.0.0",
73
73
  "concat-stream": "^2.0.0",
74
74
  "eslint": "^9.17.0",
75
75
  "fastify": "^5.1.0",
76
76
  "neostandard": "^0.12.0",
77
- "pino": "^9.1.0",
77
+ "pino": "^10.0.0",
78
78
  "proxyquire": "^2.1.3",
79
79
  "tsd": "^0.33.0"
80
80
  },
@@ -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
 
@@ -0,0 +1 @@
1
+ /none
@@ -0,0 +1 @@
1
+ ../origin
@@ -870,6 +870,83 @@ test('sendFile', async (t) => {
870
870
  })
871
871
  })
872
872
 
873
+ test('sendFile with multiple roots', async (t) => {
874
+ t.plan(4)
875
+
876
+ const pluginOptions = {
877
+ root: [path.join(__dirname, '/static'), path.join(__dirname, '/static2')],
878
+ prefix: '/static'
879
+ }
880
+ const fastify = Fastify()
881
+ const maxAge = Math.round(Math.random() * 10) * 10000
882
+ fastify.register(fastifyStatic, pluginOptions)
883
+
884
+ t.after(() => fastify.close())
885
+
886
+ fastify.get('/foo', function (_req, rep) {
887
+ rep.sendFile('foo.html')
888
+ })
889
+
890
+ fastify.get('/foo-with-options', function (_req, rep) {
891
+ rep.sendFile('foo.html', { maxAge })
892
+ })
893
+
894
+ fastify.get('/bar', function (_req, rep) {
895
+ rep.sendFile('bar.html')
896
+ })
897
+
898
+ fastify.get('/bar-with-options', function (_req, rep) {
899
+ rep.sendFile('bar.html', { maxAge })
900
+ })
901
+
902
+ await fastify.listen({ port: 0 })
903
+ fastify.server.unref()
904
+
905
+ await t.test('reply.sendFile() first root', async (t) => {
906
+ t.plan(4 + GENERIC_RESPONSE_CHECK_COUNT)
907
+
908
+ const response = await fetch('http://localhost:' + fastify.server.address().port + '/foo')
909
+ t.assert.ok(response.ok)
910
+ t.assert.deepStrictEqual(response.status, 200)
911
+ t.assert.deepStrictEqual(await response.text(), fooContent)
912
+ t.assert.deepStrictEqual(response.headers.get('cache-control'), 'public, max-age=0')
913
+ genericResponseChecks(t, response)
914
+ })
915
+
916
+ await t.test('reply.sendFile() first root with options', async (t) => {
917
+ t.plan(4 + GENERIC_RESPONSE_CHECK_COUNT)
918
+
919
+ const response = await fetch('http://localhost:' + fastify.server.address().port + '/foo-with-options')
920
+ t.assert.ok(response.ok)
921
+ t.assert.deepStrictEqual(response.status, 200)
922
+ t.assert.deepStrictEqual(await response.text(), fooContent)
923
+ t.assert.deepStrictEqual(response.headers.get('cache-control'), `public, max-age=${maxAge / 1000}`)
924
+ genericResponseChecks(t, response)
925
+ })
926
+
927
+ await t.test('reply.sendFile() second root', async (t) => {
928
+ t.plan(4 + GENERIC_RESPONSE_CHECK_COUNT)
929
+
930
+ const response = await fetch('http://localhost:' + fastify.server.address().port + '/bar')
931
+ t.assert.ok(response.ok)
932
+ t.assert.deepStrictEqual(response.status, 200)
933
+ t.assert.deepStrictEqual(await response.text(), barContent)
934
+ t.assert.deepStrictEqual(response.headers.get('cache-control'), 'public, max-age=0')
935
+ genericResponseChecks(t, response)
936
+ })
937
+
938
+ await t.test('reply.sendFile() second root with options', async (t) => {
939
+ t.plan(4 + GENERIC_RESPONSE_CHECK_COUNT)
940
+
941
+ const response = await fetch('http://localhost:' + fastify.server.address().port + '/bar-with-options')
942
+ t.assert.ok(response.ok)
943
+ t.assert.deepStrictEqual(response.status, 200)
944
+ t.assert.deepStrictEqual(await response.text(), barContent)
945
+ t.assert.deepStrictEqual(response.headers.get('cache-control'), `public, max-age=${maxAge / 1000}`)
946
+ genericResponseChecks(t, response)
947
+ })
948
+ })
949
+
873
950
  test('sendFile disabled', async (t) => {
874
951
  t.plan(1)
875
952
 
@@ -1438,7 +1515,7 @@ test('register no prefix', async (t) => {
1438
1515
  })
1439
1516
  })
1440
1517
 
1441
- test('with fastify-compress', async t => {
1518
+ test('with fastify-compress', { timeout: 60000 }, async t => {
1442
1519
  t.plan(2)
1443
1520
 
1444
1521
  const pluginOptions = {
@@ -1476,6 +1553,7 @@ test('with fastify-compress', async t => {
1476
1553
  genericResponseChecks(t, response)
1477
1554
  })
1478
1555
  })
1556
+
1479
1557
  test('register /static/ with schemaHide true', async t => {
1480
1558
  t.plan(2)
1481
1559
 
@@ -1712,7 +1790,9 @@ test('register with wildcard false (trailing slash in the root)', async t => {
1712
1790
  wildcard: false
1713
1791
  }
1714
1792
  const fastify = Fastify({
1715
- ignoreTrailingSlash: true
1793
+ routerOptions: {
1794
+ ignoreTrailingSlash: true
1795
+ }
1716
1796
  })
1717
1797
  fastify.register(fastifyStatic, pluginOptions)
1718
1798
 
@@ -3174,8 +3254,8 @@ test('should not redirect to protocol-relative locations', async (t) => {
3174
3254
  ['//^/..', '/', 301],
3175
3255
  ['//^/.', null, 404], // it is NOT recognized as a directory by pillarjs/send
3176
3256
  ['//:/..', '/', 301],
3177
- ['/\\\\a//google.com/%2e%2e%2f%2e%2e', '/a//google.com/%2e%2e%2f%2e%2e/', 301],
3178
- ['//a//youtube.com/%2e%2e%2f%2e%2e', '/a//youtube.com/%2e%2e%2f%2e%2e/', 301],
3257
+ ['/\\\\a//google.com/%2e%2e%2f%2e%2e', null, 404],
3258
+ ['//a//youtube.com/%2e%2e%2f%2e%2e', null, 404],
3179
3259
  ['/^', null, 404], // it is NOT recognized as a directory by pillarjs/send
3180
3260
  ['//google.com/%2e%2e', '/', 301],
3181
3261
  ['//users/%2e%2e', '/', 301],
@@ -3510,6 +3590,36 @@ test(
3510
3590
  }
3511
3591
  )
3512
3592
 
3593
+ test('does not serve static files with encoded path separators', async (t) => {
3594
+ t.plan(4)
3595
+
3596
+ const fastify = Fastify()
3597
+
3598
+ t.after(() => fastify.close())
3599
+
3600
+ fastify.all('/deep/*', async (_request, reply) => {
3601
+ reply.code(401).send('Unauthorized')
3602
+ })
3603
+
3604
+ fastify.register(fastifyStatic, {
3605
+ root: path.join(__dirname, '/static')
3606
+ })
3607
+
3608
+ const response = await fastify.inject({
3609
+ method: 'GET',
3610
+ url: '/deep/path/for/test/purpose/foo.html'
3611
+ })
3612
+ t.assert.deepStrictEqual(response.statusCode, 401)
3613
+ t.assert.deepStrictEqual(response.body, 'Unauthorized')
3614
+
3615
+ const encodedPathResponse = await fastify.inject({
3616
+ method: 'GET',
3617
+ url: '/deep%2Fpath/for/test/purpose/foo.html'
3618
+ })
3619
+ t.assert.deepStrictEqual(encodedPathResponse.statusCode, 404)
3620
+ t.assert.deepStrictEqual(encodedPathResponse.json().message, 'Route GET:/deep%2Fpath/for/test/purpose/foo.html not found')
3621
+ })
3622
+
3513
3623
  test('content-length in head route should not return zero when using wildcard', async t => {
3514
3624
  t.plan(5)
3515
3625
 
package/types/index.d.ts CHANGED
@@ -1,7 +1,3 @@
1
- // Definitions by: Jannik <https://github.com/jannikkeye>
2
- // Leo <https://github.com/leomelzer>
3
- /// <reference types="node" />
4
-
5
1
  import { FastifyPluginAsync, FastifyReply, FastifyRequest, RouteOptions } from 'fastify'
6
2
  import { Stats } from 'node:fs'
7
3
 
@@ -8,7 +8,7 @@ import fastifyStatic, {
8
8
  fastifyStatic as fastifyStaticNamed
9
9
  } from '..'
10
10
 
11
- import fastifyStaticCjsImport = require('..')
11
+ const fastifyStaticCjsImport = fastifyStaticStar
12
12
  const fastifyStaticCjs = require('..')
13
13
 
14
14
  const app: FastifyInstance = fastify()
package/.github/stale.yml DELETED
@@ -1,21 +0,0 @@
1
- # Number of days of inactivity before an issue becomes stale
2
- daysUntilStale: 15
3
- # Number of days of inactivity before a stale issue is closed
4
- daysUntilClose: 7
5
- # Issues with these labels will never be considered stale
6
- exemptLabels:
7
- - "discussion"
8
- - "feature request"
9
- - "bug"
10
- - "help wanted"
11
- - "plugin suggestion"
12
- - "good first issue"
13
- # Label to use when marking an issue as stale
14
- staleLabel: stale
15
- # Comment to post when marking an issue as stale. Set to `false` to disable
16
- markComment: >
17
- This issue has been automatically marked as stale because it has not had
18
- recent activity. It will be closed if no further activity occurs. Thank you
19
- for your contributions.
20
- # Comment to post when closing a stale issue. Set to `false` to disable
21
- closeComment: false