@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.
- package/.github/workflows/lock-threads.yml +19 -0
- package/index.js +63 -19
- package/lib/dirList.js +20 -6
- package/package.json +1 -1
- package/test/dir-list.test.js +82 -0
- package/test/static.test.js +113 -2
|
@@ -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
|
-
|
|
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)
|
|
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
|
|
178
|
-
? route + 'none'
|
|
179
|
-
: route
|
|
180
|
-
|
|
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
package/test/dir-list.test.js
CHANGED
|
@@ -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
|
|
package/test/static.test.js
CHANGED
|
@@ -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',
|
|
3258
|
-
['//a//youtube.com/%2e%2e%2f%2e%2e',
|
|
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
|
|