The Security Middleware Blocked the Real Agent with a 403
An agent tried to connect and got a 403. It was not an attacker. It was our own defense layer, blocking a legitimate self-hosted domain.
RelayRoom's MCP server checks the Host header. The goal is correct: self-hosted servers, especially local or private-network servers, need DNS rebinding protection. But when the security boundary reads a different configuration source than the product UI, normal traffic can look hostile. That is what happened here.
What DNS Rebinding Is
DNS rebinding is an old browser-based attack against private services. An attacker registers a domain, initially resolves it to their own web server, and serves malicious JavaScript. Later, the attacker changes DNS so the same domain resolves to an internal IP address or local service. The browser still believes the script is talking to the same origin, while the network destination has changed.
Server-side Host checks help because the HTTP Host header preserves the name the client intended to reach. If the server accepts only trusted names such as localhost or hub.example.com, arbitrary attacker-controlled domains can be rejected even if they resolve to the server's IP.
RelayRoom followed that pattern. Unexpected hosts were rejected with 403.
A Valid Domain Was Rejected
After putting a self-hosted hub behind a custom domain, agent connect failed:
error: could not fetch RELAYROOM.md ... server responded 403A direct request made the cause clear:
{"error":"host not allowed"}The proxy was not blocking the request. RelayRoom's server decided the valid custom domain was outside the allowlist.
This is the tricky part of allowlist defenses: a 403 can look like success. The middleware did what it was written to do. The bug was that its model of "allowed" did not include a real deployment path.
The Root Cause Had Two Layers
First, compose did not pass the public base URL to the server container.
The server allowlist was built from server-side environment variables such as RELAYROOM_SERVER_BASE_URL and RELAYROOM_ALLOWED_HOSTS. The compose file passed the public URL to the web container, but not to the server container that actually enforced the Host check. The web UI knew the public URL. The server did not.
Second, the product had a deeper config split.
RelayRoom lets self-host users set the server base URL in the dashboard. That value lives in the database and is used to show connect instructions. But the Host allowlist only read env-derived values. The UI could tell the user to connect to https://hub.example.com, while the server rejected hub.example.com as unknown.
That is a self-host footgun: the product tells the user which URL to use, and another layer of the same product blocks it.
Env and Database Settings Live at Different Times
The underlying design question is where configuration belongs.
Environment variables are deployment-time configuration. They are injected when the container starts and usually do not change until restart. That is useful for security middleware and bootstrap settings.
Database-backed runtime settings are product-time configuration. Users can change them in the UI without editing .env or restarting compose. That is useful for public URLs, SMTP, and other self-host ergonomics.
The problem appears when both surfaces describe the same concept. RELAYROOM_SERVER_BASE_URL and the dashboard server URL both mean "the public MCP server URL." If connect instructions read one source and Host middleware reads the other, there is no single source of truth.
The Fix: Automatically Allow the Real Public URL
The fix went in two directions.
First, compose now passes the public base URL to the server container as well as the web container.
Second, the server allowlist also reads the dashboard's database setting. If the user changes the public URL in the UI, both the connect guide and the Host allowlist recognize it. A cache can keep this cheap; the important part is that the security middleware honors the product's real configuration surface.
Conceptually:
const allowedHosts = new Set<string>();
allowedHosts.add("localhost");
allowedHosts.add("127.0.0.1");
allowedHosts.add(hostFromEnv("RELAYROOM_SERVER_BASE_URL"));
allowedHosts.addAll(hostsFromEnv("RELAYROOM_ALLOWED_HOSTS"));
allowedHosts.add(hostFromDatabaseSetting("serverBaseUrl"));
if (!allowedHosts.has(requestHost)) {
return json({ error: "host not allowed" }, 403);
}Real code needs careful URL parsing, port handling, IPv6 localhost handling, normalization, and caching. But the principle is simple: if the product tells users to connect to a URL, the server's security boundary must treat that URL as legitimate.
Do Not Fix This with *
The answer is not to allow every host. Disabling Host checks removes the DNS rebinding defense. A quick RELAYROOM_ALLOWED_HOSTS=* workaround has a way of becoming the documented path.
A good allowlist should:
- Allow local development hosts such as
localhostand127.0.0.1. - Allow the public URL provided during install.
- Allow the public URL configured in the dashboard.
- Allow only explicitly configured extra hosts.
- Normalize hostnames and ports consistently with a URL parser.
- Return a clear error without dumping internal configuration.
This Was Different from the SSE Bug
Two custom-domain issues overlapped, which made the first diagnosis confusing. One was this Host allowlist 403. The other was the reverse proxy buffering /api/sse and making wake delivery look dead.
They separate cleanly:
- A Host allowlist failure rejects ordinary requests such as
RELAYROOM.mdfetches. - SSE buffering lets ordinary requests pass but stalls the
/api/ssestream. - Comparing origin vs proxy paths helps isolate both.
Users usually report "it does not work behind my domain." The product has to split that sentence into Host checks, TLS, OAuth redirect URLs, SSE buffering, and idle timeouts.
Takeaway
Security middleware is highly sensitive to configuration ownership. Env config is controlled by operators. DB runtime config is controlled by users inside the product. If the middleware only trusts one surface, the other can create valid paths that get blocked.
Allowlist defenses should block attackers while automatically including the product's normal public URL. Otherwise users experience security as broken setup.
RelayRoom chose to honor both env and database settings. Operators can still control extra hosts with RELAYROOM_ALLOWED_HOSTS, and self-host users can change the public URL in the dashboard without breaking MCP connect.
References
- OWASP, DNS Rebinding
- Wikipedia, DNS rebinding
- RelayRoom, Self-hosting
- RelayRoom, MCP tools and scoping