Skip to content

Bug: /mcp and /sse enforce Host allowlist BEFORE the bearer token, blocking remote MCP clients even when a valid token is presented #218

Description

@zkyken

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions