From de85afd65edec9ebc44a11e245fd9e9a2e99760d Mon Sep 17 00:00:00 2001 From: Evan Wallace Date: Fri, 7 Feb 2025 20:57:56 -0500 Subject: [PATCH] Merge commit from fork --- CHANGELOG.md | 10 ++ cmd/esbuild/service.go | 8 +- lib/shared/stdio_protocol.ts | 2 +- lib/shared/types.ts | 2 +- pkg/api/api.go | 4 +- pkg/api/serve_other.go | 77 +++++++----- scripts/js-api-tests.js | 231 +++++++++++++++++++++++++++-------- 7 files changed, 249 insertions(+), 85 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b248f7bff6..89db89e359 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +* Restrict access to esbuild's development server ([GHSA-67mh-4wv8-2f99](https://github.com/evanw/esbuild/security/advisories/GHSA-67mh-4wv8-2f99)) + + This change addresses esbuild's first security vulnerability report. Previously esbuild set the `Access-Control-Allow-Origin` header to `*` to allow esbuild's development server to be flexible in how it's used for development. However, this allows the websites you visit to make HTTP requests to esbuild's local development server, which gives read-only access to your source code if the website were to fetch your source code's specific URL. You can read more information in [the report](https://github.com/evanw/esbuild/security/advisories/GHSA-67mh-4wv8-2f99). + + Starting with this release, [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) will now be disabled, and requests will now be denied if the host does not match the one provided to `--serve=`. The default host is `0.0.0.0`, which refers to all of the IP addresses that represent the local machine (e.g. both `127.0.0.1` and `192.168.0.1`). If you want to customize anything about esbuild's development server, you can [put a proxy in front of esbuild](https://esbuild.github.io/api/#serve-proxy) and modify the incoming and/or outgoing requests. + + In addition, the `serve()` API call has been changed to return an array of `hosts` instead of a single `host` string. This makes it possible to determine all of the hosts that esbuild's development server will accept. + + Thanks to [@sapphi-red](https://github.com/sapphi-red) for reporting this issue. + * Delete output files when a build fails in watch mode ([#3643](https://github.com/evanw/esbuild/issues/3643)) It has been requested for esbuild to delete files when a build fails in watch mode. Previously esbuild left the old files in place, which could cause people to not immediately realize that the most recent build failed. With this release, esbuild will now delete all output files if a rebuild fails. Fixing the build error and triggering another rebuild will restore all output files again. diff --git a/cmd/esbuild/service.go b/cmd/esbuild/service.go index 29476a5b1a..f705ba8cbc 100644 --- a/cmd/esbuild/service.go +++ b/cmd/esbuild/service.go @@ -424,11 +424,15 @@ func (service *serviceType) handleIncomingPacket(bytes []byte) { if result, err := ctx.Serve(options); err != nil { service.sendPacket(encodeErrorPacket(p.id, err)) } else { + hosts := make([]interface{}, len(result.Hosts)) + for i, host := range result.Hosts { + hosts[i] = host + } service.sendPacket(encodePacket(packet{ id: p.id, value: map[string]interface{}{ - "port": int(result.Port), - "host": result.Host, + "port": int(result.Port), + "hosts": hosts, }, })) } diff --git a/lib/shared/stdio_protocol.ts b/lib/shared/stdio_protocol.ts index 2b14630fa4..5f5749c6db 100644 --- a/lib/shared/stdio_protocol.ts +++ b/lib/shared/stdio_protocol.ts @@ -35,7 +35,7 @@ export interface ServeRequest { export interface ServeResponse { port: number - host: string + hosts: string[] } export interface BuildPlugin { diff --git a/lib/shared/types.ts b/lib/shared/types.ts index c7053070fe..d0ae5104bd 100644 --- a/lib/shared/types.ts +++ b/lib/shared/types.ts @@ -256,7 +256,7 @@ export interface ServeOnRequestArgs { /** Documentation: https://esbuild.github.io/api/#serve-return-values */ export interface ServeResult { port: number - host: string + hosts: string[] } export interface TransformOptions extends CommonOptions { diff --git a/pkg/api/api.go b/pkg/api/api.go index 2a6d1b5c70..6f426b67e8 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -491,8 +491,8 @@ type ServeOnRequestArgs struct { // Documentation: https://esbuild.github.io/api/#serve-return-values type ServeResult struct { - Port uint16 - Host string + Port uint16 + Hosts []string } type WatchOptions struct { diff --git a/pkg/api/serve_other.go b/pkg/api/serve_other.go index d8cef94191..9f95da325a 100644 --- a/pkg/api/serve_other.go +++ b/pkg/api/serve_other.go @@ -48,6 +48,7 @@ type apiHandler struct { keyfileToLower string certfileToLower string fallback string + hosts []string serveWaitGroup sync.WaitGroup activeStreams []chan serverSentEvent currentHashes map[string]string @@ -103,12 +104,6 @@ func errorsToString(errors []Message) string { func (h *apiHandler) ServeHTTP(res http.ResponseWriter, req *http.Request) { start := time.Now() - // 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) - return - } - // HEAD requests omit the body maybeWriteResponseBody := func(bytes []byte) { res.Write(bytes) } isHEAD := req.Method == "HEAD" @@ -116,9 +111,37 @@ func (h *apiHandler) ServeHTTP(res http.ResponseWriter, req *http.Request) { maybeWriteResponseBody = func([]byte) { res.Write(nil) } } + // Check the "Host" header to prevent DNS rebinding attacks + if strings.ContainsRune(req.Host, ':') { + // Try to strip off the port number + if host, _, err := net.SplitHostPort(req.Host); err == nil { + req.Host = host + } + } + if req.Host != "localhost" { + ok := false + for _, allowed := range h.hosts { + if req.Host == allowed { + ok = true + break + } + } + if !ok { + go h.notifyRequest(time.Since(start), req, http.StatusForbidden) + res.WriteHeader(http.StatusForbidden) + maybeWriteResponseBody([]byte(fmt.Sprintf("403 - Forbidden: The host %q is not allowed", req.Host))) + 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) + return + } + // Handle GET and HEAD requests if (isHEAD || req.Method == "GET") && strings.HasPrefix(req.URL.Path, "/") { - res.Header().Set("Access-Control-Allow-Origin", "*") queryPath := path.Clean(req.URL.Path)[1:] result := h.rebuild() @@ -360,7 +383,6 @@ func (h *apiHandler) serveEventStream(start time.Time, req *http.Request, res ht res.Header().Set("Content-Type", "text/event-stream") res.Header().Set("Connection", "keep-alive") res.Header().Set("Cache-Control", "no-cache") - res.Header().Set("Access-Control-Allow-Origin", "*") go h.notifyRequest(time.Since(start), req, http.StatusOK) res.WriteHeader(http.StatusOK) res.Write([]byte("retry: 500\n")) @@ -789,11 +811,26 @@ func (ctx *internalContext) Serve(serveOptions ServeOptions) (ServeResult, error // Extract the real port in case we passed a port of "0" var result ServeResult + var boundHost string if host, text, err := net.SplitHostPort(addr); err == nil { if port, err := strconv.ParseInt(text, 10, 32); err == nil { result.Port = uint16(port) - result.Host = host + boundHost = host + } + } + + // Build up a list of all hosts we use + if ip := net.ParseIP(boundHost); ip != nil && ip.IsUnspecified() { + // If this is "0.0.0.0" or "::", list all relevant IP addresses + if addrs, err := net.InterfaceAddrs(); err == nil { + for _, addr := range addrs { + if addr, ok := addr.(*net.IPNet); ok && (addr.IP.To4() != nil) == (ip.To4() != nil) && !addr.IP.IsLinkLocalUnicast() { + result.Hosts = append(result.Hosts, addr.IP.String()) + } + } } + } else { + result.Hosts = append(result.Hosts, boundHost) } // HTTPS-related files should be absolute paths @@ -815,6 +852,7 @@ func (ctx *internalContext) Serve(serveOptions ServeOptions) (ServeResult, error keyfileToLower: strings.ToLower(serveOptions.Keyfile), certfileToLower: strings.ToLower(serveOptions.Certfile), fallback: serveOptions.Fallback, + hosts: append([]string{}, result.Hosts...), rebuild: func() BuildResult { if atomic.LoadInt32(&shouldStop) != 0 { // Don't start more rebuilds if we were told to stop @@ -905,7 +943,7 @@ func (ctx *internalContext) Serve(serveOptions ServeOptions) (ServeResult, error // Print the URL(s) that the server can be reached at if ctx.args.logOptions.LogLevel <= logger.LevelInfo { - printURLs(result.Host, result.Port, isHTTPS, ctx.args.logOptions.Color) + printURLs(handler.hosts, result.Port, isHTTPS, ctx.args.logOptions.Color) } // Start the first build shortly after this function returns (but not @@ -941,28 +979,11 @@ func (hack *hackListener) Accept() (net.Conn, error) { return hack.Listener.Accept() } -func printURLs(host string, port uint16, https bool, useColor logger.UseColor) { +func printURLs(hosts []string, port uint16, https bool, useColor logger.UseColor) { logger.PrintTextWithColor(os.Stderr, useColor, func(colors logger.Colors) string { - var hosts []string sb := strings.Builder{} sb.WriteString(colors.Reset) - // If this is "0.0.0.0" or "::", list all relevant IP addresses - if ip := net.ParseIP(host); ip != nil && ip.IsUnspecified() { - if addrs, err := net.InterfaceAddrs(); err == nil { - for _, addr := range addrs { - if addr, ok := addr.(*net.IPNet); ok && (addr.IP.To4() != nil) == (ip.To4() != nil) && !addr.IP.IsLinkLocalUnicast() { - hosts = append(hosts, addr.IP.String()) - } - } - } - } - - // Otherwise, just list the one IP address - if len(hosts) == 0 { - hosts = append(hosts, host) - } - // Determine the host kinds kinds := make([]string, len(hosts)) maxLen := 0 diff --git a/scripts/js-api-tests.js b/scripts/js-api-tests.js index 90cf2d64f7..303a0ac4a0 100644 --- a/scripts/js-api-tests.js +++ b/scripts/js-api-tests.js @@ -4271,7 +4271,7 @@ let serveTests = { // Try fetching the non-existent file try { - await fetch(server.host, server.port, '/' + path.basename(outfile)) + await fetch(server.hosts[0], server.port, '/' + path.basename(outfile)) throw new Error('Expected an error to be thrown') } catch (err) { if (err.statusCode !== 503) throw err @@ -4288,7 +4288,7 @@ let serveTests = { } // Try fetching the file now - const data = await fetch(server.host, server.port, '/' + path.basename(outfile)) + const data = await fetch(server.hosts[0], server.port, '/' + path.basename(outfile)) assert.strictEqual(data.toString(), 'throw 4;\n') } finally { await context.dispose() @@ -4312,13 +4312,13 @@ let serveTests = { host: '127.0.0.1', onRequest: args => onRequest(args), }) - assert.strictEqual(result.host, '127.0.0.1'); + assert.deepStrictEqual(result.hosts, ['127.0.0.1']); assert.strictEqual(typeof result.port, 'number'); // GET /in.js { const singleRequestPromise = new Promise(resolve => { onRequest = resolve }); - const buffer = await fetch(result.host, result.port, '/in.js') + const buffer = await fetch(result.hosts[0], result.port, '/in.js') assert.strictEqual(buffer.toString(), `console.log(123);\n`); assert.strictEqual(fs.readFileSync(input, 'utf8'), `console.log(123)`) @@ -4333,7 +4333,7 @@ let serveTests = { // HEAD /in.js { const singleRequestPromise = new Promise(resolve => { onRequest = resolve }); - const buffer = await fetch(result.host, result.port, '/in.js', { method: 'HEAD' }) + const buffer = await fetch(result.hosts[0], result.port, '/in.js', { method: 'HEAD' }) assert.strictEqual(buffer.toString(), ``); // HEAD omits the content assert.strictEqual(fs.readFileSync(input, 'utf8'), `console.log(123)`) @@ -4389,10 +4389,10 @@ let serveTests = { certfile, onRequest, }) - assert.strictEqual(result.host, '127.0.0.1'); + assert.deepStrictEqual(result.hosts, ['127.0.0.1']); assert.strictEqual(typeof result.port, 'number'); - const buffer = await fetchHTTPS(result.host, result.port, '/in.js', { certfile }) + const buffer = await fetchHTTPS(result.hosts[0], result.port, '/in.js', { certfile }) assert.strictEqual(buffer.toString(), `console.log(123);\n`); assert.strictEqual(fs.readFileSync(input, 'utf8'), `console.log(123)`) @@ -4419,65 +4419,65 @@ let serveTests = { host: '127.0.0.1', servedir: testDir, }) - assert.strictEqual(result.host, '127.0.0.1'); + assert.deepStrictEqual(result.hosts, ['127.0.0.1']); assert.strictEqual(typeof result.port, 'number'); // With a trailing slash { - const buffer = await fetch(result.host, result.port, '/nested/dir/index.html') + const buffer = await fetch(result.hosts[0], result.port, '/nested/dir/index.html') assert.strictEqual(buffer.toString(), ``) } { - const buffer = await fetch(result.host, result.port, '/nested/dir/') + const buffer = await fetch(result.hosts[0], result.port, '/nested/dir/') assert.strictEqual(buffer.toString(), ``) } { - const buffer = await fetch(result.host, result.port, '/nested/./dir/') + const buffer = await fetch(result.hosts[0], result.port, '/nested/./dir/') assert.strictEqual(buffer.toString(), ``) } { - const buffer = await fetch(result.host, result.port, '/./nested/./dir/./') + const buffer = await fetch(result.hosts[0], result.port, '/./nested/./dir/./') assert.strictEqual(buffer.toString(), ``) } { - const buffer = await fetch(result.host, result.port, '/nested/dir//') + const buffer = await fetch(result.hosts[0], result.port, '/nested/dir//') assert.strictEqual(buffer.toString(), ``) } { - const buffer = await fetch(result.host, result.port, '/nested//dir/') + const buffer = await fetch(result.hosts[0], result.port, '/nested//dir/') assert.strictEqual(buffer.toString(), ``) } // Without a trailing slash { - const res = await partialFetch(result.host, result.port, '/nested') + const res = await partialFetch(result.hosts[0], result.port, '/nested') assert.strictEqual(res.statusCode, 302) assert.strictEqual(res.headers.location, '/nested/') } { - const res = await partialFetch(result.host, result.port, '/nested/dir') + const res = await partialFetch(result.hosts[0], result.port, '/nested/dir') assert.strictEqual(res.statusCode, 302) assert.strictEqual(res.headers.location, '/nested/dir/') } { - const res = await partialFetch(result.host, result.port, '/nested//dir') + const res = await partialFetch(result.hosts[0], result.port, '/nested//dir') assert.strictEqual(res.statusCode, 302) assert.strictEqual(res.headers.location, '/nested/dir/') } // With leading double slashes (looks like a protocol-relative URL) { - const res = await partialFetch(result.host, result.port, '//nested') + const res = await partialFetch(result.hosts[0], result.port, '//nested') assert.strictEqual(res.statusCode, 302) assert.strictEqual(res.headers.location, '/nested/') } { - const res = await partialFetch(result.host, result.port, '//nested/dir') + const res = await partialFetch(result.hosts[0], result.port, '//nested/dir') assert.strictEqual(res.statusCode, 302) assert.strictEqual(res.headers.location, '/nested/dir/') } { - const res = await partialFetch(result.host, result.port, '//nested//dir') + const res = await partialFetch(result.hosts[0], result.port, '//nested//dir') assert.strictEqual(res.statusCode, 302) assert.strictEqual(res.headers.location, '/nested/dir/') } @@ -4506,10 +4506,10 @@ let serveTests = { host: '127.0.0.1', onRequest, }) - assert.strictEqual(result.host, '127.0.0.1'); + assert.deepStrictEqual(result.hosts, ['127.0.0.1']); assert.strictEqual(typeof result.port, 'number'); - const buffer = await fetch(result.host, result.port, '/out.js') + const buffer = await fetch(result.hosts[0], result.port, '/out.js') assert.strictEqual(buffer.toString(), `console.log(123);\n`); let singleRequest = await singleRequestPromise; @@ -4520,7 +4520,7 @@ let serveTests = { assert.strictEqual(typeof singleRequest.timeInMS, 'number'); try { - await fetch(result.host, result.port, '/in.js') + await fetch(result.hosts[0], result.port, '/in.js') throw new Error('Expected a 404 error for "/in.js"') } catch (err) { if (err.message !== '404 when fetching "/in.js": 404 - Not Found') @@ -4563,13 +4563,13 @@ let serveTests = { onRequest: args => onRequest(args), servedir: wwwDir, }) - assert.strictEqual(result.host, '127.0.0.1'); + assert.deepStrictEqual(result.hosts, ['127.0.0.1']); assert.strictEqual(typeof result.port, 'number'); let promise, buffer, req; promise = nextRequestPromise; - buffer = await fetch(result.host, result.port, '/in.js') + buffer = await fetch(result.hosts[0], result.port, '/in.js') assert.strictEqual(buffer.toString(), `console.log(123);\n`); assert.strictEqual(fs.existsSync(path.join(wwwDir, path.basename(input))), false); req = await promise; @@ -4580,7 +4580,7 @@ let serveTests = { assert.strictEqual(typeof req.timeInMS, 'number'); promise = nextRequestPromise; - buffer = await fetch(result.host, result.port, '/') + buffer = await fetch(result.hosts[0], result.port, '/') assert.strictEqual(buffer.toString(), ``); req = await promise; assert.strictEqual(req.method, 'GET'); @@ -4660,13 +4660,13 @@ let serveTests = { onRequest: args => onRequest(args), servedir: wwwDir, }) - assert.strictEqual(result.host, '127.0.0.1'); + assert.deepStrictEqual(result.hosts, ['127.0.0.1']); assert.strictEqual(typeof result.port, 'number'); let promise, buffer, req; promise = nextRequestPromise; - buffer = await fetch(result.host, result.port, '/out/in.js') + buffer = await fetch(result.hosts[0], result.port, '/out/in.js') assert.strictEqual(buffer.toString(), `console.log(123);\n`); req = await promise; assert.strictEqual(req.method, 'GET'); @@ -4676,7 +4676,7 @@ let serveTests = { assert.strictEqual(typeof req.timeInMS, 'number'); promise = nextRequestPromise; - buffer = await fetch(result.host, result.port, '/') + buffer = await fetch(result.hosts[0], result.port, '/') assert.strictEqual(buffer.toString(), ``); req = await promise; assert.strictEqual(req.method, 'GET'); @@ -4688,7 +4688,7 @@ let serveTests = { // Make sure the output directory prefix requires a slash separator promise = nextRequestPromise; try { - await fetch(result.host, result.port, '/outin.js') + await fetch(result.hosts[0], result.port, '/outin.js') throw new Error('Expected an error to be thrown') } catch (err) { if (err.statusCode !== 404) throw err @@ -4727,13 +4727,13 @@ let serveTests = { onRequest: args => onRequest(args), servedir: testDir, }) - assert.strictEqual(result.host, '127.0.0.1'); + assert.deepStrictEqual(result.hosts, ['127.0.0.1']); assert.strictEqual(typeof result.port, 'number'); let promise, buffer, req; promise = nextRequestPromise; - buffer = await fetch(result.host, result.port, '/') + buffer = await fetch(result.hosts[0], result.port, '/') assert.strictEqual(buffer.toString(), ``); req = await promise; assert.strictEqual(req.method, 'GET'); @@ -4746,7 +4746,7 @@ let serveTests = { // "fs.FS" object in Go does not cache the result of calling "ReadDirectory") await fs.promises.unlink(index) promise = nextRequestPromise; - buffer = await fetch(result.host, result.port, '/') + buffer = await fetch(result.hosts[0], result.port, '/') assert.notStrictEqual(buffer.toString(), '') req = await promise; assert.strictEqual(req.method, 'GET'); @@ -4783,12 +4783,11 @@ let serveTests = { // Subtract 1 because range headers are inclusive on both ends Range: `bytes=${start}-${start + length - 1}`, } - const fetched = await fetch(result.host, result.port, '/big.txt', { headers }) + const fetched = await fetch(result.hosts[0], result.port, '/big.txt', { headers }) delete fetched.headers.date // This changes every time delete fetched.headers.connection // Node v19+ no longer sends this const expected = buffer.slice(start, start + length) expected.headers = { - 'access-control-allow-origin': '*', 'content-length': `${length}`, 'content-range': `bytes ${start}-${start + length - 1}/${byteCount}`, 'content-type': 'application/octet-stream', @@ -4819,14 +4818,14 @@ let serveTests = { }) await context.watch() - const js = await fetch(server.host, server.port, '/out/script.js') + const js = await fetch(server.hosts[0], server.port, '/out/script.js') assert.strictEqual(js.toString(), `console.log(123);\n`); - const explicitHTML = await fetch(server.host, server.port, '/out/index.html') + const explicitHTML = await fetch(server.hosts[0], server.port, '/out/index.html') assert.strictEqual(explicitHTML.toString(), ``); // The server should support implicit "index.html" extensions on entry point files - const implicitHTML = await fetch(server.host, server.port, '/out/') + const implicitHTML = await fetch(server.hosts[0], server.port, '/out/') assert.strictEqual(implicitHTML.toString(), ``); // Make a change to the HTML @@ -4862,7 +4861,7 @@ let serveTests = { const server = await context.serve({ host: '127.0.0.1', }) - const stream = await makeEventStream(server.host, server.port, '/esbuild') + const stream = await makeEventStream(server.hosts[0], server.port, '/esbuild') await context.rebuild().then( () => Promise.reject(new Error('Expected an error to be thrown')), () => { /* Ignore the build error due to the missing JS file */ }, @@ -4934,7 +4933,7 @@ let serveTests = { host: '127.0.0.1', servedir: testDir, }) - const stream = await makeEventStream(server.host, server.port, '/esbuild') + const stream = await makeEventStream(server.hosts[0], server.port, '/esbuild') await context.rebuild().then( () => Promise.reject(new Error('Expected an error to be thrown')), () => { /* Ignore the build error due to the missing JS file */ }, @@ -5006,7 +5005,7 @@ let serveTests = { const server = await context.serve({ host: '127.0.0.1', }) - const stream = await makeEventStream(server.host, server.port, '/esbuild') + const stream = await makeEventStream(server.hosts[0], server.port, '/esbuild') await context.rebuild().then( () => Promise.reject(new Error('Expected an error to be thrown')), () => { /* Ignore the build error due to the missing JS file */ }, @@ -5079,7 +5078,7 @@ let serveTests = { host: '127.0.0.1', servedir: testDir, }) - const stream = await makeEventStream(server.host, server.port, '/esbuild') + const stream = await makeEventStream(server.hosts[0], server.port, '/esbuild') await context.rebuild().then( () => Promise.reject(new Error('Expected an error to be thrown')), () => { /* Ignore the build error due to the missing JS file */ }, @@ -5154,29 +5153,158 @@ let serveTests = { servedir: wwwDir, fallback, }) - assert.strictEqual(result.host, '127.0.0.1'); + assert.deepStrictEqual(result.hosts, ['127.0.0.1']); assert.strictEqual(typeof result.port, 'number'); let buffer; - buffer = await fetch(result.host, result.port, '/in.js') + buffer = await fetch(result.hosts[0], result.port, '/in.js') assert.strictEqual(buffer.toString(), `console.log(123);\n`); - buffer = await fetch(result.host, result.port, '/') + buffer = await fetch(result.hosts[0], result.port, '/') assert.strictEqual(buffer.toString(), `

fallback

`); - buffer = await fetch(result.host, result.port, '/app/') + buffer = await fetch(result.hosts[0], result.port, '/app/') assert.strictEqual(buffer.toString(), `

index

`); - buffer = await fetch(result.host, result.port, '/app/?foo') + buffer = await fetch(result.hosts[0], result.port, '/app/?foo') assert.strictEqual(buffer.toString(), `

index

`); - buffer = await fetch(result.host, result.port, '/app/foo') + buffer = await fetch(result.hosts[0], result.port, '/app/foo') assert.strictEqual(buffer.toString(), `

fallback

`); } finally { await context.dispose(); } }, + + async serveHostCheckIPv4({ esbuild, testDir }) { + const input = path.join(testDir, 'in.js') + await writeFileAsync(input, `console.log(123)`) + + let onRequest; + + const context = await esbuild.context({ + entryPoints: [input], + format: 'esm', + outdir: testDir, + write: false, + }); + try { + const result = await context.serve({ + port: 0, + onRequest: args => onRequest(args), + }) + assert(result.hosts.includes('127.0.0.1')); + assert.strictEqual(typeof result.port, 'number'); + + // GET /in.js from each host + for (const host of result.hosts) { + const singleRequestPromise = new Promise(resolve => { onRequest = resolve }); + const buffer = await fetch(host, result.port, '/in.js') + assert.strictEqual(buffer.toString(), `console.log(123);\n`); + assert.strictEqual(fs.readFileSync(input, 'utf8'), `console.log(123)`) + + let singleRequest = await singleRequestPromise; + assert.strictEqual(singleRequest.method, 'GET'); + assert.strictEqual(singleRequest.path, '/in.js'); + assert.strictEqual(singleRequest.status, 200); + assert.strictEqual(typeof singleRequest.remoteAddress, 'string'); + assert.strictEqual(typeof singleRequest.timeInMS, 'number'); + } + + // GET /in.js with a forbidden host header + const forbiddenHosts = [ + 'evil.com', + 'evil.com:666', + '1.2.3.4', + '1.2.3.4:666', + '::1234', + '[::1234]:666', + '[', + ] + for (const forbiddenHost of forbiddenHosts) { + const singleRequestPromise = new Promise(resolve => { onRequest = resolve }); + try { + await fetch(result.hosts[0], result.port, '/in.js', { headers: { Host: forbiddenHost } }) + } catch { + } + + let singleRequest = await singleRequestPromise; + assert.strictEqual(singleRequest.method, 'GET'); + assert.strictEqual(singleRequest.path, '/in.js'); + assert.strictEqual(singleRequest.status, 403, forbiddenHost); // 403 means "Forbidden" + assert.strictEqual(typeof singleRequest.remoteAddress, 'string'); + assert.strictEqual(typeof singleRequest.timeInMS, 'number'); + } + } finally { + await context.dispose(); + } + }, + + async serveHostCheckIPv6({ esbuild, testDir }) { + const input = path.join(testDir, 'in.js') + await writeFileAsync(input, `console.log(123)`) + + let onRequest; + + const context = await esbuild.context({ + entryPoints: [input], + format: 'esm', + outdir: testDir, + write: false, + }); + try { + const result = await context.serve({ + host: '::', + port: 0, + onRequest: args => onRequest(args), + }) + assert(result.hosts.includes('::1')); + assert.strictEqual(typeof result.port, 'number'); + + // GET /in.js from each host + for (const host of result.hosts) { + const singleRequestPromise = new Promise(resolve => { onRequest = resolve }); + const buffer = await fetch(host, result.port, '/in.js') + assert.strictEqual(buffer.toString(), `console.log(123);\n`); + assert.strictEqual(fs.readFileSync(input, 'utf8'), `console.log(123)`) + + let singleRequest = await singleRequestPromise; + assert.strictEqual(singleRequest.method, 'GET'); + assert.strictEqual(singleRequest.path, '/in.js'); + assert.strictEqual(singleRequest.status, 200); + assert.strictEqual(typeof singleRequest.remoteAddress, 'string'); + assert.strictEqual(typeof singleRequest.timeInMS, 'number'); + } + + // GET /in.js with a forbidden host header + const forbiddenHosts = [ + 'evil.com', + 'evil.com:666', + '1.2.3.4', + '1.2.3.4:666', + '::1234', + '[::1234]:666', + '[', + ] + for (const forbiddenHost of forbiddenHosts) { + const singleRequestPromise = new Promise(resolve => { onRequest = resolve }); + try { + await fetch(result.hosts[0], result.port, '/in.js', { headers: { Host: forbiddenHost } }) + } catch { + } + + let singleRequest = await singleRequestPromise; + assert.strictEqual(singleRequest.method, 'GET'); + assert.strictEqual(singleRequest.path, '/in.js'); + assert.strictEqual(singleRequest.status, 403, forbiddenHost); // 403 means "Forbidden" + assert.strictEqual(typeof singleRequest.remoteAddress, 'string'); + assert.strictEqual(typeof singleRequest.timeInMS, 'number'); + } + } finally { + await context.dispose(); + } + }, } async function futureSyntax(esbuild, js, targetBelow, targetAbove) { @@ -7522,7 +7650,7 @@ let childProcessTests = { }, } -let syncTests = { +let serialTests = { async startStop({ esbuild }) { for (let i = 0; i < 3; i++) { let result1 = await esbuild.transform('1+2') @@ -7564,7 +7692,6 @@ async function main() { process.exit(1) }, minutes * 60 * 1000) - // Run all tests concurrently const runTest = async (name, fn) => { let testDir = path.join(rootTestDir, name) try { @@ -7589,6 +7716,7 @@ async function main() { ...Object.entries(childProcessTests), ] + // Run everything in "tests" concurrently let allTestsPassed = (await Promise.all(tests.map(([name, fn]) => { const promise = runTest(name, fn) @@ -7601,7 +7729,8 @@ async function main() { return promise.finally(() => clearTimeout(timeout)) }))).every(success => success) - for (let [name, fn] of Object.entries(syncTests)) { + // Run everything in "serialTests" in serial + for (let [name, fn] of Object.entries(serialTests)) { if (!await runTest(name, fn)) { allTestsPassed = false }