security: forbid \\ in local dev server requests

This commit is contained in:
Evan Wallace
2026-06-11 17:12:15 -04:00
parent 040db75a5e
commit 659fbb8376
3 changed files with 70 additions and 0 deletions
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
* Disallow `\\` in local development server HTTP requests ([GHSA-g7r4-m6w7-qqqr](https://github.com/evanw/esbuild/security/advisories/GHSA-g7r4-m6w7-qqqr))
This release fixes a security issue where HTTP requests to esbuild's local development server could traverse outside of the serve directory on Windows using a `\\` backslash character. It happened due to the use of Go's `path.Clean()` function, which only handles Unix-style `/` characters. HTTP requests with paths containing `\\` are no longer allowed.
Thanks to [@dellalibera](https://github.com/dellalibera) for reporting this issue.
* Avoid inlining `using` and `await using` declarations ([#4482](https://github.com/evanw/esbuild/issues/4482))
Previously esbuild's minifier sometimes incorrectly inlined `using` and `await using` declarations into subsequent uses of that declaration, which then fails to dispose of the resource correctly. This bug happened because inlining was done for `let` and `const` declarations by avoiding doing it for `var` declarations, which no longer worked when more declaration types were added. Here's an example:
+8
View File
@@ -154,6 +154,14 @@ func (h *apiHandler) ServeHTTP(res http.ResponseWriter, req *http.Request) {
}
}
// All requests containing Windows-style path separators are invalid
if strings.ContainsRune(req.URL.Path, '\\') {
go h.notifyRequest(time.Since(start), req, http.StatusBadRequest)
res.WriteHeader(http.StatusBadRequest)
maybeWriteResponseBody([]byte("400 - Bad Request"))
return
}
// Special-case the esbuild event stream
if req.Method == "GET" && req.URL.Path == "/esbuild" && req.Header.Get("Accept") == "text/event-stream" {
h.serveEventStream(start, req, res)
+56
View File
@@ -5602,6 +5602,62 @@ let serveTests = {
await context.dispose();
}
},
// https://github.com/evanw/esbuild/security/advisories/GHSA-g7r4-m6w7-qqqr
async serveDirectoryTraversalUsingBackslash({ esbuild, testDir }) {
const failure = path.join(testDir, 'failure.txt')
const servedir = path.join(testDir, 'root')
const input = path.join(servedir, 'in.ts')
await mkdirAsync(servedir)
await writeFileAsync(failure, `TEST FAILURE`)
await writeFileAsync(input, `console.log(123)`)
let onRequest;
const context = await esbuild.context({
entryPoints: [input],
format: 'esm',
outdir: servedir,
write: false,
});
try {
const result = await context.serve({
host: '127.0.0.1',
servedir,
onRequest: args => onRequest(args),
})
assert.deepStrictEqual(result.hosts, ['127.0.0.1']);
assert.strictEqual(typeof result.port, 'number');
// GET ..\failure.txt
{
const singleRequestPromise = new Promise(resolve => { onRequest = resolve });
try {
const buffer = await fetch(result.hosts[0], result.port, '/..\\failure.txt')
throw new Error('Unexpected response: ' + buffer)
} catch (e) {
if (e.statusCode !== 400) throw e
}
const args = await singleRequestPromise
if (args.status !== 400) throw new Error('Unexpected args: ' + JSON.stringify(args))
}
// GET ..%5cfailure.txt
{
const singleRequestPromise = new Promise(resolve => { onRequest = resolve });
try {
const buffer = await fetch(result.hosts[0], result.port, '/..%5cfailure.txt')
throw new Error('Unexpected response: ' + buffer)
} catch (e) {
if (e.statusCode !== 400) throw e
}
const args = await singleRequestPromise
if (args.status !== 400) throw new Error('Unexpected args: ' + JSON.stringify(args))
}
} finally {
await context.dispose();
}
},
}
async function futureSyntax(esbuild, js, targetBelow, targetAbove) {