Class: Woods::MCP::OriginGuard
- Inherits:
-
Object
- Object
- Woods::MCP::OriginGuard
- Defined in:
- lib/woods/mcp/origin_guard.rb
Overview
Rack middleware that rejects browser-origin requests from unexpected sources.
Defends against DNS rebinding and cross-site request forgery against a locally-bound MCP HTTP server. Defaults to loopback-only origins; operators can widen via WOODS_MCP_HTTP_ALLOWED_ORIGINS (comma-separated) or by passing :allowed_origins. Requests without an Origin header (curl, server-to-server, MCP stdio clients) are allowed through — bearer auth still gates them.
Host header validation defends against the residual DNS-rebinding surface: an attacker who controls a hostname they can point at the server’s IP can pass the Origin check (the browser sends their origin, which we might allow-list for some deployments) while Host carries their hostname. By also requiring Host to appear in the allow-list (or to be a loopback address), we close that gap even when Rails is bound to 0.0.0.0.
Port-matching: an allow-list entry WITHOUT a port (‘localhost`) matches that host on any port. An entry WITH a port (`localhost:3000`) requires an exact port match. Specify explicit ports when port isolation matters.
Also answers CORS preflight (OPTIONS) with the matching allow-list.
Constant Summary collapse
- DEFAULT_ALLOWED =
%w[ http://localhost http://127.0.0.1 http://[::1] https://localhost https://127.0.0.1 https://[::1] ].freeze
- LOOPBACK_HOSTS =
Hosts that always pass the Host-header check even without an explicit allow-list entry — they resolve to loopback by definition and cannot be rebound to an attacker-controlled address.
%w[localhost 127.0.0.1 ::1 [::1]].freeze
- ALLOWED_METHODS =
'GET, POST, DELETE, OPTIONS'- ALLOWED_HEADERS =
'Authorization, Content-Type, Mcp-Session-Id'- FORBIDDEN_BODY =
Response bodies are emitted as constants so the rejected Origin / Host value is NEVER echoed back to the caller — preventing a stored-XSS / log-injection surface where an attacker-supplied header ended up embedded in the JSON error.
{ jsonrpc: '2.0', error: { code: -32_002, message: 'Origin not allowed' }, id: nil }.to_json.freeze
- FORBIDDEN_HOST_BODY =
{ jsonrpc: '2.0', error: { code: -32_002, message: 'Host not allowed' }, id: nil }.to_json.freeze
Instance Method Summary collapse
- #call(env) ⇒ Object
-
#initialize(app, allowed_origins: nil) ⇒ OriginGuard
constructor
A new instance of OriginGuard.
Constructor Details
#initialize(app, allowed_origins: nil) ⇒ OriginGuard
Returns a new instance of OriginGuard.
51 52 53 54 55 56 |
# File 'lib/woods/mcp/origin_guard.rb', line 51 def initialize(app, allowed_origins: nil) @app = app @allowed = Array(allowed_origins).compact.reject { |o| o.to_s.strip.empty? }.map { |o| normalize(o) } @allowed = DEFAULT_ALLOWED.dup if @allowed.empty? @allowed_hosts = @allowed.map { |o| extract_host(o) }.compact.uniq end |
Instance Method Details
#call(env) ⇒ Object
58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
# File 'lib/woods/mcp/origin_guard.rb', line 58 def call(env) origin = env['HTTP_ORIGIN'] method = env['REQUEST_METHOD'] host = env['HTTP_HOST'] return forbidden if origin && !origin_allowed?(origin) return forbidden_host if host && !host_allowed?(host) return preflight(origin) if method == 'OPTIONS' status, headers, body = @app.call(env) headers = cors_headers(origin).merge(headers) if origin && origin_allowed?(origin) [status, headers, body] end |