Skip to content

Commit e2a1a71

Browse files
committed
security: forbid \\ in local dev server requests
1 parent 83a2cbf commit e2a1a71

3 files changed

Lines changed: 70 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## Unreleased
44

5+
* Disallow `\\` in local development server HTTP requests ([GHSA-g7r4-m6w7-qqqr](https://github.com/evanw/esbuild/security/advisories/GHSA-g7r4-m6w7-qqqr))
6+
7+
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.
8+
9+
Thanks to [@dellalibera](https://github.com/dellalibera) for reporting this issue.
10+
511
* Avoid inlining `using` and `await using` declarations ([#4482](https://github.com/evanw/esbuild/issues/4482))
612

713
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:

pkg/api/serve_other.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,14 @@ func (h *apiHandler) ServeHTTP(res http.ResponseWriter, req *http.Request) {
154154
}
155155
}
156156

157+
// All requests containing Windows-style path separators are invalid
158+
if strings.ContainsRune(req.URL.Path, '\\') {
159+
go h.notifyRequest(time.Since(start), req, http.StatusBadRequest)
160+
res.WriteHeader(http.StatusBadRequest)
161+
maybeWriteResponseBody([]byte("400 - Bad Request"))
162+
return
163+
}
164+
157165
// Special-case the esbuild event stream
158166
if req.Method == "GET" && req.URL.Path == "/esbuild" && req.Header.Get("Accept") == "text/event-stream" {
159167
h.serveEventStream(start, req, res)

scripts/js-api-tests.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5602,6 +5602,62 @@ let serveTests = {
56025602
await context.dispose();
56035603
}
56045604
},
5605+
5606+
// https://github.com/evanw/esbuild/security/advisories/GHSA-g7r4-m6w7-qqqr
5607+
async serveDirectoryTraversalUsingBackslash({ esbuild, testDir }) {
5608+
const failure = path.join(testDir, 'failure.txt')
5609+
const servedir = path.join(testDir, 'root')
5610+
const input = path.join(servedir, 'in.ts')
5611+
await mkdirAsync(servedir)
5612+
await writeFileAsync(failure, `TEST FAILURE`)
5613+
await writeFileAsync(input, `console.log(123)`)
5614+
5615+
let onRequest;
5616+
5617+
const context = await esbuild.context({
5618+
entryPoints: [input],
5619+
format: 'esm',
5620+
outdir: servedir,
5621+
write: false,
5622+
});
5623+
try {
5624+
const result = await context.serve({
5625+
host: '127.0.0.1',
5626+
servedir,
5627+
onRequest: args => onRequest(args),
5628+
})
5629+
assert.deepStrictEqual(result.hosts, ['127.0.0.1']);
5630+
assert.strictEqual(typeof result.port, 'number');
5631+
5632+
// GET ..\failure.txt
5633+
{
5634+
const singleRequestPromise = new Promise(resolve => { onRequest = resolve });
5635+
try {
5636+
const buffer = await fetch(result.hosts[0], result.port, '/..\\failure.txt')
5637+
throw new Error('Unexpected response: ' + buffer)
5638+
} catch (e) {
5639+
if (e.statusCode !== 400) throw e
5640+
}
5641+
const args = await singleRequestPromise
5642+
if (args.status !== 400) throw new Error('Unexpected args: ' + JSON.stringify(args))
5643+
}
5644+
5645+
// GET ..%5cfailure.txt
5646+
{
5647+
const singleRequestPromise = new Promise(resolve => { onRequest = resolve });
5648+
try {
5649+
const buffer = await fetch(result.hosts[0], result.port, '/..%5cfailure.txt')
5650+
throw new Error('Unexpected response: ' + buffer)
5651+
} catch (e) {
5652+
if (e.statusCode !== 400) throw e
5653+
}
5654+
const args = await singleRequestPromise
5655+
if (args.status !== 400) throw new Error('Unexpected args: ' + JSON.stringify(args))
5656+
}
5657+
} finally {
5658+
await context.dispose();
5659+
}
5660+
},
56055661
}
56065662

56075663
async function futureSyntax(esbuild, js, targetBelow, targetAbove) {

0 commit comments

Comments
 (0)