Class: Woods::MCP::OriginGuard

Inherits:
Object
  • Object
show all
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

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