Class: Otto::CaddyTLS::LocalhostGuard

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

  1. The socket peer (+REMOTE_ADDR+) is loopback.
  2. 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

Constructor Details

#initialize(app, endpoint) ⇒ LocalhostGuard

Returns a new instance of LocalhostGuard.

Parameters:

  • app (#call)

    the downstream Rack app

  • endpoint (String)

    the path to protect (e.g. ‘/_caddy/tls-permission’)



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.

Parameters:

  • env (Hash)

    Rack environment

Returns:

  • (Array)

    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