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 reads the ORIGINAL +env[‘REMOTE_ADDR’]+ — the TCP socket peer — and MUST run before +IPPrivacyMiddleware+ rewrites +REMOTE_ADDR+ from forwarded headers. Installed via +Otto#use+ (appended, hence outermost in the reduce-built stack) it always executes ahead of +IPPrivacyMiddleware+ (which is pinned innermost), so it inspects the true socket 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/reverse-proxy-network-services.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. Mirrors Otto::Utils::FORWARDED_FOR_HEADERS plus RFC 7239 Forwarded.
%w[ HTTP_X_FORWARDED_FOR HTTP_X_REAL_IP HTTP_X_CLIENT_IP HTTP_FORWARDED ].freeze
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.
74 75 76 77 |
# File 'lib/otto/caddy_tls/localhost_guard.rb', line 74 def initialize(app, endpoint) @app = app @endpoint = normalize_path(endpoint) end |
Instance Method Details
#call(env) ⇒ Array
Returns Rack response tuple.
81 82 83 84 85 86 |
# File 'lib/otto/caddy_tls/localhost_guard.rb', line 81 def call(env) return @app.call(env) unless targets_endpoint?(env) return deny unless direct_local_call?(env) @app.call(env) end |