Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

### Changed

- Restore compatibility with reverse proxies that rewrite the upstream Host
header by relying on configured authentication instead of comparing Host and
Origin authorities.

## 0.3.1 - 2026-08-10

### Changed
Expand Down
11 changes: 2 additions & 9 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,8 @@ interact with terminal sessions, run repository hooks, read session data, and
upload or delete workspace files. Anyone who can access the UI should be
treated as having the same authority as the user running herdr-gui.

The server binds to `127.0.0.1` by default. In this unauthenticated loopback
mode it accepts only `localhost`, `127.0.0.1`, and `::1` request hosts to block
DNS-rebinding access from hostile web origins. Browser requests and WebSocket
handshakes must also carry an Origin matching the requested authority. A
reverse proxy using another host name must run herdr-gui in authenticated
non-loopback mode and preserve the original Host value.

Do not expose herdr-gui directly to the public internet. When binding to a
non-loopback address:
The server binds to `127.0.0.1` by default. Do not expose it directly to the
public internet. When binding to a non-loopback address:

- Set a strong `HERDR_GUI_PASSWORD`.
- Prefer `HERDR_GUI_PASSWORD` over the `--password` flag so the password is not
Expand Down
95 changes: 17 additions & 78 deletions server/src/http/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,88 +11,27 @@ function cookieHeader(response: Response): string {
return cookie.split(";", 1)[0];
}

describe("request host validation", () => {
test("allows only loopback origins when authentication is disabled", () => {
const handlers = createAuthHandlers({
authRequired: false,
password: "",
});

for (const url of [
"http://localhost:8787/",
"http://localhost.:8787/",
"http://127.0.0.1:8787/",
"http://[::1]:8787/",
]) {
expect(handlers.isAllowedRequestHost(new Request(url))).toBe(true);
}
expect(
handlers.isAllowedRequestHost(
new Request("http://attacker.example:8787/"),
),
).toBe(false);
expect(
handlers.isAllowedRequestHost(new Request("http://127.0.0.2:8787/")),
).toBe(false);
});

test("rejects browser origins from another authority", () => {
const handlers = createAuthHandlers({
authRequired: false,
password: "",
});
expect(
handlers.isAllowedRequestOrigin(
new Request("http://localhost:8787/ws", {
headers: { origin: "http://localhost:8787" },
}),
),
).toBe(true);
expect(
handlers.isAllowedRequestOrigin(
new Request("http://localhost:5173/ws", {
headers: { origin: "http://localhost:5173" },
}),
),
).toBe(true);
expect(
handlers.isAllowedRequestOrigin(
new Request("http://dashboard.example.com/ws", {
headers: { origin: "https://dashboard.example.com" },
}),
),
).toBe(true);
expect(
handlers.isAllowedRequestOrigin(
new Request("http://127.0.0.1:8787/ws", {
headers: { origin: "https://attacker.example" },
}),
),
).toBe(false);
expect(
handlers.isAllowedRequestOrigin(
new Request("http://127.0.0.1:8787/ws", {
headers: { origin: "null" },
}),
),
).toBe(false);
expect(
handlers.isAllowedRequestOrigin(
new Request("http://127.0.0.1:8787/healthz"),
),
).toBe(true);
});

test("relies on signed authentication for non-loopback deployments", () => {
describe("request authentication boundaries", () => {
test("does not derive authorization from reverse-proxy authorities", async () => {
const handlers = createAuthHandlers({
authRequired: true,
password: "fixed-password",
});
expect(
handlers.isAllowedRequestHost(
new Request("https://dashboard.example.com/"),
),
).toBe(true);
const login = await handlers.handleLogin(
new Request("http://upstream.example/api/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ password: "fixed-password" }),
}),
);
const proxiedRequest = new Request("http://upstream.example/ws", {
headers: {
cookie: cookieHeader(login),
origin: "https://dashboard.example.com",
},
});

expect(handlers.isAuthed(proxiedRequest)).toBe(true);
});
});

Expand Down
42 changes: 1 addition & 41 deletions server/src/http/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,39 +122,6 @@ export function createAuthHandlers(args: {
}
}

function isAllowedRequestHost(req: Request): boolean {
if (args.authRequired) return true;
let hostname: string;
try {
hostname = new URL(req.url).hostname.toLowerCase();
} catch {
return false;
}
return (
hostname === "localhost" ||
hostname === "localhost." ||
hostname === "127.0.0.1" ||
hostname === "[::1]"
);
}

function isAllowedRequestOrigin(req: Request): boolean {
const origin = req.headers.get("origin");
if (origin === null) return true;
try {
const originUrl = new URL(origin);
const requestUrl = new URL(req.url);
return (
(originUrl.protocol === "http:" || originUrl.protocol === "https:") &&
!originUrl.username &&
!originUrl.password &&
originUrl.host.toLowerCase() === requestUrl.host.toLowerCase()
);
} catch {
return false;
}
}

function isAuthed(req: Request): boolean {
if (!args.authRequired) return true;
const token = parseCookie(req.headers.get("cookie"), AUTH_COOKIE);
Expand Down Expand Up @@ -220,12 +187,5 @@ export function createAuthHandlers(args: {
});
}

return {
isAllowedRequestHost,
isAllowedRequestOrigin,
isAuthed,
handleTokenLogin,
handleLogin,
loginPage,
};
return { isAuthed, handleTokenLogin, handleLogin, loginPage };
}
26 changes: 2 additions & 24 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,8 @@ const APP_VERSION = packageJson.version;
const serviceCommandExitCode = runServiceCommand(process.argv.slice(2));
if (serviceCommandExitCode !== null) process.exit(serviceCommandExitCode);
const config = loadServerConfig(APP_VERSION);
const {
isAllowedRequestHost,
isAllowedRequestOrigin,
isAuthed,
handleTokenLogin,
handleLogin,
loginPage,
} = createAuthHandlers({
const { isAuthed, handleTokenLogin, handleLogin, loginPage } =
createAuthHandlers({
authRequired: config.authRequired,
password: config.password,
urlLoginToken: config.generatedAuthToken,
Expand Down Expand Up @@ -803,22 +797,6 @@ async function main() {
async fetch(req, server) {
const url = new URL(req.url);

// Reject foreign browser origins before serving even health or login
// routes. The loopback Host allowlist also prevents a hostile web origin
// from DNS-rebinding to 127.0.0.1 and controlling the privileged API.
if (!isAllowedRequestHost(req)) {
return new Response("invalid host", {
status: 421,
headers: { "cache-control": "no-store" },
});
}
if (!isAllowedRequestOrigin(req)) {
return new Response("invalid origin", {
status: 403,
headers: { "cache-control": "no-store" },
});
}

const tokenLoginResponse = handleTokenLogin(req);
if (tokenLoginResponse) return tokenLoginResponse;

Expand Down
12 changes: 3 additions & 9 deletions web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,11 @@ export default defineConfig({
server: {
port: 5173,
proxy: {
// Preserve the browser-facing authority so the bridge can validate the
// Origin header for HTTP and WebSocket requests.
"/ws": {
target: "http://127.0.0.1:8787",
ws: true,
changeOrigin: false,
},
"/api": { target: "http://127.0.0.1:8787", changeOrigin: false },
"/ws": { target: "http://127.0.0.1:8787", ws: true },
"/api": { target: "http://127.0.0.1:8787" },
// Let an unauthenticated dev client reach the bridge login page instead
// of repeatedly loading the Vite SPA at /login and redirecting again.
"/login": { target: "http://127.0.0.1:8787", changeOrigin: false },
"/login": { target: "http://127.0.0.1:8787" },
},
},
});