Bug: /mcp and /sse enforce Host allowlist BEFORE the bearer token, blocking remote MCP clients even when a valid token is presented
Environment
- wigolo: v0.2.1 (
ghcr.io/knockoutez/wigolo)
- Server mode:
wigolo serve --host 0.0.0.0 --port 3333 (bind past loopback + WIGOLO_API_TOKEN set)
- Client: MCP-over-StreamableHTTP client (Hermes agent) using
Authorization: Bearer <token> from inside a docker network — request arrives with Host: wigolo:3333 (the docker DNS name), not 127.0.0.1
- Symptoms: every
/mcp POST returns 403 host_not_allowed before the bearer token is ever checked; same path works for /v1/search (REST) when called from the same client
What the README promises
/mcp + /sse serve remote MCP clients from the same port. Bind past loopback and a bearer token is required, so the server fails closed by default.
What is actually happening
Two independent auth code paths in dist/daemon/, opposite decisions for the same client:
| Path |
File |
Auth order |
Behavior |
/v1/*, /openapi.json |
rest/router.js -> rest/auth.js::checkAuth() |
Bearer token first - Host allowlist SKIPPED in token mode |
OK |
/mcp, /sse, /messages |
http-server.js::mcpTransportRejected() |
Host allowlist FIRST, then origin, then token |
403 |
README's "remote MCP clients get past loopback when a bearer token is configured" - works in concept (token configured) but MCP handler rejects on Host before consulting the token.
Reproduction
$ docker inspect wigolo --format '{{json .Config.Cmd}}'
["serve","--host","0.0.0.0","--port","3333"]
$ docker exec wigolo printenv WIGOLO_API_TOKEN | head -c 20
c4bbd070bc611a4589338 # length 64 hex, configured
# Peer container (Hermes agent), Host=wigolo:3333
$ curl -si -m5 -X POST http://wigolo:3333/mcp \
-H "Authorization: Bearer $WIGOLO_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}'
HTTP/1.1 403 Forbidden
{"error_reason":"host_not_allowed","hint":"Request Host is not on the loopback allowlist."}
# REST from same peer, same auth, same token: works
$ curl -si -m5 -X POST http://127.0.0.1:3333/v1/search \
-H "Authorization: Bearer $WIGOLO_API_TOKEN" ...
HTTP/1.1 200 OK
Root cause (cite)
dist/daemon/http-server.js, the mcpTransportRejected() method (line 80+):
private mcpTransportRejected(req, res) {
if (!this.isAllowedHost(req.headers.host)) { // <-- checks BEFORE token
// 403 host_not_allowed
return true;
}
if (req.headers.origin !== undefined) { ... }
if (!this.apiToken) return false;
// ... bearer token check happens here, downstream of the host gate
}
isAllowedHost() (line 220) allowlist: localhost, 127.0.0.1, [::1], ::1, and this.host (which, for --host 0.0.0.0, is 0.0.0.0). A peer-container Host of wigolo:3333 is none of those. Set.has returns false, we never reach the token check.
The REST checkAuth() docstring already states the intended design: "Token mode (token configured): Bearer required; Host allowlist SKIPPED". MCP path was not aligned.
Suggested fix
Mirror the REST contract in mcpTransportRejected(): when this.apiToken is set, skip the host allowlist; require a valid bearer token instead. Keep the Origin guard running before the token check (matches REST admin route discipline - defense in depth against a browser page probing a leaked token).
private mcpTransportRejected(req, res): boolean {
// Origin guard first - applies in BOTH modes (matches REST admin route).
if (req.headers.origin !== undefined) {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'browser origin not allowed',
error_reason: 'origin_not_allowed' }));
return true;
}
// Token mode: host allowlist SKIPPED, valid Bearer required.
if (this.apiToken) {
const provided = (req.headers.authorization ?? '').startsWith('Bearer ')
? req.headers.authorization.slice('Bearer '.length).trim() : null;
if (!tokenMatches(this.apiToken, provided)) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'unauthorized',
error_reason: 'unauthorized',
hint: 'Provide a valid "Authorization: Bearer <token>" header.' }));
return true;
}
return false;
}
// Open mode: keep the loopback-host allowlist.
if (!this.isAllowedHost(req.headers.host)) { /* ... existing 403 ... */ }
return false;
}
This aligns both code paths with the README contract and with checkAuth()'s comment.
Why this matters
- README explicitly markets
/mcp//sse for "Hermes-style assistants, n8n, any self-hosted agent" - practically every containerized deployment hits this
- Any peer on the same docker network (non-loopback Host) is locked out without a sidecar rewriting Host headers - adds a component to fix a missing feature
- Fix is small (<=30 lines), localized to one file, matches the design REST auth already proves out
- Currently blocks every feature test that wants to exercise the 10 MCP tools from a real MCP client (vs internal curl)
Workaround in use (NOT a long-term answer)
Running a sidecar that does socat 127.0.0.1:13333 -> wigolo:3333 so the client can send Host: 127.0.0.1:13333 (loopback). Adds an extra container + an extra hop; defeats the point of exposing /mcp to remote MCP clients per the README.
Happy to send a PR if that'd help - just say the word on whether a patch or maintainer-style fix lands first.
Bug:
/mcpand/sseenforce Host allowlist BEFORE the bearer token, blocking remote MCP clients even when a valid token is presentedEnvironment
ghcr.io/knockoutez/wigolo)wigolo serve --host 0.0.0.0 --port 3333(bind past loopback +WIGOLO_API_TOKENset)Authorization: Bearer <token>from inside a docker network — request arrives withHost: wigolo:3333(the docker DNS name), not127.0.0.1/mcpPOST returns403 host_not_allowedbefore the bearer token is ever checked; same path works for/v1/search(REST) when called from the same clientWhat the README promises
What is actually happening
Two independent auth code paths in
dist/daemon/, opposite decisions for the same client:/v1/*,/openapi.jsonrest/router.js->rest/auth.js::checkAuth()/mcp,/sse,/messageshttp-server.js::mcpTransportRejected()README's "remote MCP clients get past loopback when a bearer token is configured" - works in concept (token configured) but MCP handler rejects on Host before consulting the token.
Reproduction
Root cause (cite)
dist/daemon/http-server.js, themcpTransportRejected()method (line 80+):isAllowedHost()(line 220) allowlist:localhost,127.0.0.1,[::1],::1, andthis.host(which, for--host 0.0.0.0, is0.0.0.0). A peer-container Host ofwigolo:3333is none of those. Set.has returns false, we never reach the token check.The REST
checkAuth()docstring already states the intended design: "Token mode (token configured): Bearer required; Host allowlist SKIPPED". MCP path was not aligned.Suggested fix
Mirror the REST contract in
mcpTransportRejected(): whenthis.apiTokenis set, skip the host allowlist; require a valid bearer token instead. Keep the Origin guard running before the token check (matches REST admin route discipline - defense in depth against a browser page probing a leaked token).This aligns both code paths with the README contract and with
checkAuth()'s comment.Why this matters
/mcp//ssefor "Hermes-style assistants, n8n, any self-hosted agent" - practically every containerized deployment hits thisWorkaround in use (NOT a long-term answer)
Running a sidecar that does
socat 127.0.0.1:13333 -> wigolo:3333so the client can sendHost: 127.0.0.1:13333(loopback). Adds an extra container + an extra hop; defeats the point of exposing/mcpto remote MCP clients per the README.Happy to send a PR if that'd help - just say the word on whether a patch or maintainer-style fix lands first.