Skip to content

Commit 71e2519

Browse files
authored
Merge commit from fork
1 parent da31a7d commit 71e2519

6 files changed

Lines changed: 694 additions & 12 deletions

File tree

‎README.md‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,12 @@ The following options are also supported and will be passed directly to the
215215
- [`index`](https://www.npmjs.com/package/@fastify/send#index)
216216
- [`lastModified`](https://www.npmjs.com/package/@fastify/send#lastmodified)
217217
- [`maxAge`](https://www.npmjs.com/package/@fastify/send#maxage)
218+
- [`onFile`](https://www.npmjs.com/package/@fastify/send#onfile)
218219
219220
These options can be altered when calling `reply.sendFile('filename.html', options)` or `reply.sendFile('filename.html', 'otherfilename.html', options)` on each response.
220221
222+
`onFile(path, stat)` receives the selected file's absolute path and file system statistics. It may return a promise, which is awaited before response processing and streaming begin. A thrown error or rejected promise aborts the operation and is handled by Fastify's error pipeline. When `allowedPath` is configured, final-path authorization runs first; denied files do not invoke `onFile`.
223+
221224
#### `redirect`
222225
223226
Default: `false`
@@ -254,10 +257,10 @@ when using the `wildcard: false` option.
254257
255258
Default: `(pathName, root, request) => true`
256259
257-
This function filters served files. Accepted `pathName` values are normalized before the callback runs; non-canonical forms such as `//file`, `/./file`, and `/path//file` are rejected. A root-relative filesystem alias that resolves a candidate with different letter casing is rejected before that candidate is authorized or served. Using the request object, complex path authentication is possible.
258-
Returning `true` serves the file; returning `false` calls Fastify's 404 handler.
260+
This function filters served files. It runs for the normalized requested `pathName` and may run again when index or extension resolution selects a different final logical path. Final paths use the same leading-slash or relative style as the requested path and are relative to the `root` that selected the file. When `sendFile` is called with an absolute path and no root, `root` is `undefined` and requested and final paths remain absolute. Non-canonical forms such as `//file`, `/./file`, and `/path//file` are rejected. A root-relative filesystem alias that resolves a candidate with different letter casing is rejected before that candidate is authorized or served. Using the request object, complex path authentication is possible.
261+
Returning `true` from every invocation serves the file; returning `false` for either the requested or final path calls Fastify's 404 handler.
259262
260-
When using `preCompressed: true`, `allowedPath` receives the requested path rather than the selected `.br` or `.gz` variant; see the `preCompressed` note below before using extension-based access rules.
263+
When using `preCompressed: true`, `.br`, `.gz`, and `.deflate` representations are authorized as their logical uncompressed path. For example, a selected compressed index is authorized as `/dir/index.html`, not `/dir/index.html.br`; see the `preCompressed` note below before using extension-based access rules.
261264
262265
#### `index`
263266
@@ -478,7 +481,7 @@ First, try to send the brotli encoded asset (if supported by `Accept-Encoding` h
478481
479482
When `preCompressed` is enabled the response includes `Vary: Accept-Encoding`, because the served variant is selected from the request `Accept-Encoding` header. This applies even when the uncompressed file is sent as a fallback, so that shared caches do not return the wrong variant to clients with a different `Accept-Encoding`.
480483
481-
> ⚠ Warning: `allowedPath` is evaluated against the requested path rather than the selected pre-compressed variant. A request for `/file.txt` can serve `/file.txt.gz` or `/file.txt.br` when the client accepts that encoding, even if direct requests to `.gz` or `.br` files are denied by `allowedPath`. Treat pre-compressed files as public alternate encodings of the same asset: keep restricted compressed files outside the served root, disable `preCompressed`, or deny the base path too.
484+
> ⚠ Warning: `allowedPath` evaluates pre-compressed variants as their logical uncompressed paths. A request for `/file.txt` can serve `/file.txt.gz`, `/file.txt.br`, or `/file.txt.deflate` when the client accepts that encoding, without separately authorizing the compression suffix. Treat pre-compressed files as alternate encodings of the same asset and apply access rules to the logical path.
482485
483486
Assume this structure with the compressed asset as a sibling of the uncompressed counterpart:
484487

‎index.js‎

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const asteriskRegex = /\*/gu
2424
const dotDotSegmentRegex = /(?:^|[\\/])\.\.(?:[\\/]|$)/u
2525
const leadingDotDotSegmentRegex = /^\/?\.\.(?:[\\/]|$)/u
2626
const encodedHashOrQuestionMarkRegex = /%(?:23|3f)/giu
27+
const finalPathDenied = Symbol('finalPathDenied')
2728

2829
const supportedEncodings = ['br', 'gzip', 'deflate']
2930
send.mime.default_type = 'application/octet-stream'
@@ -68,7 +69,8 @@ async function fastifyStatic (fastify, opts) {
6869
immutable: opts.immutable,
6970
index: opts.index,
7071
lastModified: opts.lastModified,
71-
maxAge: opts.maxAge
72+
maxAge: opts.maxAge,
73+
onFile: opts.onFile
7274
}
7375

7476
let prefix = opts.prefix ??= '/'
@@ -110,7 +112,7 @@ async function fastifyStatic (fastify, opts) {
110112
opts,
111113
undefined,
112114
allowedPath !== undefined
113-
)
115+
).catch((error) => this.send(error))
114116
return this
115117
})
116118

@@ -133,7 +135,7 @@ async function fastifyStatic (fastify, opts) {
133135
opts,
134136
undefined,
135137
allowedPath !== undefined
136-
)
138+
).catch((error) => this.send(error))
137139

138140
return this
139141
}
@@ -162,7 +164,7 @@ async function fastifyStatic (fastify, opts) {
162164
return reply.callNotFound()
163165
}
164166

165-
pumpSendToReply(
167+
return pumpSendToReply(
166168
req,
167169
reply,
168170
pathname,
@@ -274,6 +276,7 @@ async function fastifyStatic (fastify, opts) {
274276
const pathnameOrig = pathname
275277
const normalizedPathname = normalizeRequestPathname(pathname)
276278
const options = Object.assign({}, sendOptions, pumpOptions)
279+
const callerOnFile = options.onFile
277280

278281
if (rootPath) {
279282
if (Array.isArray(rootPath)) {
@@ -361,14 +364,50 @@ async function fastifyStatic (fastify, opts) {
361364
return reply.callNotFound()
362365
}
363366

367+
if (allowedPath && (callerOnFile === undefined || typeof callerOnFile === 'function')) {
368+
options.onFile = async function authorizeFinalPath (selectedPath, selectedStat) {
369+
let logicalPath = selectedPath
370+
const encodingExtension = encodingExtensionMap[encoding]
371+
if (encodingExtension && logicalPath.endsWith(encodingExtension)) {
372+
logicalPath = logicalPath.slice(0, -encodingExtension.length)
373+
}
374+
375+
const finalPathname = getFinalPathname(
376+
logicalPath,
377+
options.root,
378+
normalizedPathname.startsWith('/')
379+
)
380+
if (
381+
finalPathname === undefined ||
382+
(finalPathname !== normalizedPathname && !allowedPath(finalPathname, options.root, request))
383+
) {
384+
throw finalPathDenied
385+
}
386+
387+
if (callerOnFile) {
388+
await callerOnFile(selectedPath, selectedStat)
389+
}
390+
}
391+
}
392+
364393
// `send(..., path, ...)` will URI-decode path so we pass an encoded path here
394+
let sendResult
395+
try {
396+
sendResult = await send(request.raw, encodeURI(pathnameForSend), options)
397+
} catch (error) {
398+
if (error === finalPathDenied) {
399+
return reply.callNotFound()
400+
}
401+
throw error
402+
}
403+
365404
const {
366405
statusCode,
367406
headers,
368407
stream,
369408
type,
370409
metadata
371-
} = await send(request.raw, encodeURI(pathnameForSend), options)
410+
} = sendResult
372411
switch (type) {
373412
case 'directory': {
374413
const path = metadata.path
@@ -659,6 +698,33 @@ function normalizeRequestPathname (pathname) {
659698
return path.posix.normalize(pathname)
660699
}
661700

701+
/**
702+
* Convert the absolute path selected by @fastify/send back to the pathname
703+
* style used by allowedPath.
704+
*
705+
* @param {string} selectedPath
706+
* @param {string|undefined} root
707+
* @param {boolean} leadingSlash
708+
* @returns {string|undefined}
709+
*/
710+
function getFinalPathname (selectedPath, root, leadingSlash) {
711+
if (root === undefined) {
712+
return selectedPath
713+
}
714+
715+
const relativePath = path.relative(path.resolve(root), selectedPath)
716+
if (
717+
relativePath === '..' ||
718+
relativePath.startsWith('..' + path.sep) ||
719+
path.isAbsolute(relativePath)
720+
) {
721+
return
722+
}
723+
724+
const posixPath = relativePath.split(path.sep).join(path.posix.sep)
725+
return leadingSlash ? '/' + posixPath : posixPath
726+
}
727+
662728
/**
663729
* @param {string} pathname
664730
* @returns {boolean}

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
"dependencies": {
6262
"@fastify/accept-negotiator": "^2.0.0",
6363
"@fastify/error": "^4.0.0",
64-
"@fastify/send": "^4.0.0",
64+
"@fastify/send": "^4.2.0",
6565
"content-disposition": "^3.0.0",
6666
"fastify-plugin": "^6.0.0",
6767
"fastq": "^1.17.1",

0 commit comments

Comments
 (0)