Class: Otto::CaddyTLS::LocalhostGuard
- Inherits:
-
Object
- Object
- Otto::CaddyTLS::LocalhostGuard
- Defined in:
- lib/otto/caddy_tls/localhost_guard.rb
Overview
Path-scoped Rack middleware that only allows requests to a single endpoint when they originate from the loopback interface.
Introduced for the Caddy on-demand TLS endpoint but written to be generic: it protects any single endpoint whose only legitimate caller is a co-located process (a reverse proxy's control-plane callback). Pass it the endpoint path to protect, and it 401s any request to that path whose connecting peer is not loopback. Every other path passes straight through, so installing it never affects the rest of the application. (It lives under Otto::CaddyTLS while it has a single consumer; promote it to a shared home if a second internal-only integration ever needs it.)
Security: authenticate the RAW peer, not the resolved client IP
The guard authenticates the TCP socket peer as it arrived, before
IPPrivacyMiddleware rewrites REMOTE_ADDR from forwarded headers.
IPPrivacyMiddleware is pinned OUTERMOST (issue #219), so it runs ahead
of this guard and records its verdict on the untouched peer as
env['otto.peer_loopback'] — a boolean, never an address. The guard reads
that record when present and falls back to evaluating REMOTE_ADDR
itself when it is not (no Otto privacy middleware in the stack, or the
guard mounted outside Otto). Either way the decision is made on the raw
peer.
Reading Otto's resolved otto.client_ip (or the rewritten REMOTE_ADDR)
would be exploitable: a co-located reverse proxy on loopback is itself a
natural trusted proxy, so an attacker who could reach the endpoint through
it and send X-Forwarded-For: 127.0.0.1 would be promoted to "localhost".
Authenticating the raw peer removes forwarded headers from the trust
decision entirely.
What "a direct local call" means
The endpoint's only legitimate caller is the co-located service making a direct request over the loopback interface. Two things must both hold:
- The socket peer (+REMOTE_ADDR+) is loopback.
- The request carries NO forwarding headers. Caddy's on-demand permission
request is a direct backend call and sends none; a request that was
relayed through a reverse proxy carries
X-Forwarded-For(or a sibling). Rejecting those is what makes the guard safe even when the endpoint is accidentally mounted inside a public app behind a proxy that connects to the backend over loopback — there, every proxied request has a loopback peer, but it also carries a forwarding header, so it is denied.
Deployment assumption
The guard trusts that REMOTE_ADDR is the real socket peer and that a
trusted layer has not stripped forwarding headers before Otto sees them.
The strongest isolation is still network-level: bind the endpoint on a
dedicated loopback-only port that the proxy reaches directly (see
examples/caddy_tls_demo/standalone.ru). Blocking the endpoint path at the
proxy is a sound additional layer. See
docs/adr/adr-003-caddy-tls-route-based-integration.md.
Constant Summary collapse
- FORWARDED_HEADERS =
Forwarding headers whose presence means the request was relayed by a proxy rather than issued directly. Any one present => not a direct local call. The forwarded-for family plus RFC 7239 Forwarded, shared with IPPrivacyMiddleware's pre-scrub record so the two cannot drift.
Otto::Utils::RELAY_MARKER_HEADERS
Instance Method Summary collapse
-
#call(env) ⇒ Array
Rack response tuple.
-
#initialize(app, endpoint) ⇒ LocalhostGuard
constructor
A new instance of LocalhostGuard.
Constructor Details
#initialize(app, endpoint) ⇒ LocalhostGuard
Returns a new instance of LocalhostGuard.
73 74 75 76 |
# File 'lib/otto/caddy_tls/localhost_guard.rb', line 73 def initialize(app, endpoint) @app = app @endpoint = normalize_path(endpoint) end |
Instance Method Details
#call(env) ⇒ Array
Returns Rack response tuple.
80 81 82 83 84 85 |
# File 'lib/otto/caddy_tls/localhost_guard.rb', line 80 def call(env) return @app.call(env) unless targets_endpoint?(env) return deny unless direct_local_call?(env) @app.call(env) end |