Host check fails in all cases except localhost
https://github.com/evanw/esbuild/blame/492e299ce6fa15ee237234887711e3f461fff415/pkg/api/serve_other.go#L141
This completely breaks using esbuild serve inside a docker container, You can not set "localhost", it must be "0.0.0.0" to bind to the port and when any request comes in at all from any other place it fails and replies with 403 forbidden.
When reporting a bug or requesting a feature, please do the following:
-
Describe what esbuild is doing incorrectly and what it should be doing instead.
-
Provide a way to reproduce the issue. The best way to do this is to demonstrate the issue on the playground (https://esbuild.github.io/try/) and paste the URL here. A link to a minimal code sample with instructions for how to reproduce the issue may also work. Issues without a way to reproduce them may be closed.
Esbuild does a check for whether the host is "localhost" or not and if it is not it then checks against a list of allowed hosts to see if it is allowed and if not it returns a 403.
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
}
}
This is fine and all but You can not set the allowed hosts through the config
type ServeOptions struct {
Port int
Host string
Servedir string
Keyfile string
Certfile string
Fallback string
CORS CORSOptions
OnRequest func(ServeOnRequestArgs)
}
so if You set the props as:
await serve({
port: 3000,
servedir: `./dist`,
host: '0.0.0.0',
fallback: `./dist/index.html`,
});
and then place this inside a docker container and then request it from another url it fails because 0.0.0.0 is not "localhost" exact match. The logic needs updated to include checks for 0.0.0.0 and 127.0.0.1 for true "localhost" check or we should be allowed to update the allowed hosts list.
You still have not provided a way to reproduce the issue. Reproduction instructions need to be specific, unambiguous, and self-contained. "place this inside a docker container and then request it from another url" isn't enough. Place with what configuration? What is the requested URL? With what tool is the URL requested (this will determine HTTP headers, for example)?
Marking as unactionable due to lack of a reproduction.
Reproduction Instructions
Files
hosts
127.0.0.1 test.dev
dockerfile
FROM node:22.17.0-bullseye-slim
# Install system deps
RUN apt-get update && \
apt-get install -y --no-install-recommends && \
apt-get clean && rm -rf /var/lib/apt/lists/*
# Set working dir
WORKDIR /usr/src/app
COPY ./package.json ./yarn.lock ./tsconfig.json ./
# Install deps
RUN npm i -g tsx
RUN yarn install --pure-lockfile --silent --non-interactive --production=false --ignore-scripts && yarn cache clean --force
# Set env paths
ENV PATH=/usr/src/app/node_modules/.bin:$PATH
ENV NODE_PATH=.
COPY ./.esbuild ./.esbuild
COPY ./app ./app
# Expose ports
EXPOSE 3000
# Set executable + ownership
RUN chown -R node:node /usr/src/app
# Drop to non-root user
USER node
# Start with dumb-init + custom watch-restart script
CMD tsx ./.esbuild/dev.ts
.esbuild/config.ts
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import esbuildSvelte from 'esbuild-svelte';
import { copy } from 'esbuild-plugin-copy';
import { type BuildOptions } from 'esbuild';
const base = resolve(fileURLToPath(new URL(import.meta.url)), '..');
export const config: BuildOptions = {
entryPoints: {
init: `./app/init.ts`,
},
mainFields: ['svelte', 'browser', 'module', 'main'],
conditions: ['svelte', 'browser'],
bundle: true,
splitting: true,
outdir: `${base}/dist`,
format: 'esm',
sourcemap: NODE_ENV !== 'production',
minify: NODE_ENV === 'production',
allowOverwrite: true,
platform: 'browser',
plugins: [
esbuildSvelte({
compilerOptions: { customElement: true },
preprocess: sveltePreprocess(),
filterWarnings: (warning) => {
/* Block Warnings Here */
return true;
},
}),
copy({
resolveFrom: base,
assets: [
{ from: `./app/index.html`, to: `./dist/index.html` },
],
watch: true,
}),
],
logLevel: NODE_ENV !== 'production' ? 'debug' : 'silent',
metafile: NODE_ENV !== 'production',
}
.esbuild/dev.ts
import { context } from 'esbuild';
import config from './config';
const { serve, watch } = await context(config).catch((err) => {
console.error('CONTEXT::', err);
return process.exit(1);
});
await watch().catch((err) => {
console.error('WATCH::', err);
return process.exit(1);
});
await serve({
port,
servedir: `./dist`,
host: '0.0.0.0',
fallback: `./dist/index.html`,
}).catch((err) => {
console.error('SERVE::', err);
return process.exit(1);
});
Steps
- Update your hosts file to have a custom domain for local development
- Create a simple svelte app without sveltekit:
npx sv create, name itapp - Having all necessary files in place build the docker image and run it on port 3000
- Attempt to go to http://test.dev:3000 or 127.0.0.1:3000 This will fail because of the before mentioned go code
I have since reverted back to v0.24 due to this bug, this is a simple reproduction but we have many services running in docker and needed nginx to route to all the different internal docker instances to avoid port overlapping on a multi-site/microservice architecture
I took a look at this. With the caveat that I am very unfamiliar with docker, I believe this can be done without any changes to esbuild. First, change the host in dev.ts:
- host: '0.0.0.0',
+ host: 'test.dev',
Then use --add-host test.dev:0.0.0.0 with docker run which from what I understand modifies /etc/hosts in the container. That seemed to work for me. This information is from the release notes regarding /etc/hosts and esbuild's serve API. It's not in esbuild's documentation for the serve API yet, although I can add it if this solution works for you as well.
I believe the host check is excessively conservative.
Simply checking for and allowing requests with either "Sec-Fetch-Mode: navigate" or "Sec-Fetch-Site: same-origin" would be enough. Those cannot be spoofed in the browser, and would only be present in safe requests. Note that the initial navigation to a site will have the "sec-fetch-site: cross-site" header along with mode: navigate.
The current documentation suggests users implement a proxy server instead, which doesn't have any CORS protection. It would be better to have a good default, where browsing directly to the the esbuild http endpoint works, and any cross-origin requests don't.
See also: https://www.alexedwards.net/blog/preventing-csrf-in-go