@fastify/static 9.1.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.
- package/.github/workflows/lock-threads.yml +19 -0
- package/index.js +65 -19
- package/lib/dirList.js +20 -6
- package/package.json +1 -1
- package/test/dir-list.test.js +82 -0
- package/test/static/shallow/no-link +1 -0
- package/test/static-symbolic-link/dir/symlink +1 -0
- package/test/static.test.js +32 -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, 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
|
|
@@ -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,40 @@ function getEncodingHeader (headers, checked) {
|
|
|
556
568
|
)
|
|
557
569
|
}
|
|
558
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
|
+
|
|
559
605
|
/**
|
|
560
606
|
* @param {string} url
|
|
561
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)
|
|
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
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/none
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
../origin
|
package/test/static.test.js
CHANGED
|
@@ -3254,8 +3254,8 @@ test('should not redirect to protocol-relative locations', async (t) => {
|
|
|
3254
3254
|
['//^/..', '/', 301],
|
|
3255
3255
|
['//^/.', null, 404], // it is NOT recognized as a directory by pillarjs/send
|
|
3256
3256
|
['//:/..', '/', 301],
|
|
3257
|
-
['/\\\\a//google.com/%2e%2e%2f%2e%2e',
|
|
3258
|
-
['//a//youtube.com/%2e%2e%2f%2e%2e',
|
|
3257
|
+
['/\\\\a//google.com/%2e%2e%2f%2e%2e', null, 404],
|
|
3258
|
+
['//a//youtube.com/%2e%2e%2f%2e%2e', null, 404],
|
|
3259
3259
|
['/^', null, 404], // it is NOT recognized as a directory by pillarjs/send
|
|
3260
3260
|
['//google.com/%2e%2e', '/', 301],
|
|
3261
3261
|
['//users/%2e%2e', '/', 301],
|
|
@@ -3590,6 +3590,36 @@ test(
|
|
|
3590
3590
|
}
|
|
3591
3591
|
)
|
|
3592
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
|
+
|
|
3593
3623
|
test('content-length in head route should not return zero when using wildcard', async t => {
|
|
3594
3624
|
t.plan(5)
|
|
3595
3625
|
|