Skip to content

EndpointChecker: harden rl.php accessibility check and improve diagnostic messaging #50

@jjroelofs

Description

@jjroelofs

Background

EndpointChecker::check() (src/Service/EndpointChecker.php) decides whether rl.php is reachable by POSTing action=ping to the URL Drupal builds for the current request and accepting any HTTP 200 as success. In its current form the check produces false negatives in surprisingly common environments and the resulting hook_requirements() warning ("rl.php is not accessible") doesn't tell the operator why.

Concrete failure we hit

Drupal multisite behind an nginx → Apache reverse proxy. Symptom: editor-facing pages display the rl-php-warning.js toast ("A/B test results are not being collected…"), even though hitting https://<site>/modules/contrib/rl/rl.php directly returns pong.

What the check actually does:

  1. $request->getSchemeAndHttpHost() returns http://<host> because $settings['reverse_proxy'] isn't set, so Drupal doesn't trust X-Forwarded-Proto.
  2. Guzzle POSTs to that http:// URL.
  3. nginx 301-redirects to HTTPS using $server_name (which collapses wildcards), losing the subdomain.
  4. Guzzle auto-follows the 3xx and lands on a different host returning a 200 HTML page (or 404).
  5. Either way, getStatusCode() === 200 is the only condition checked → result depends on the redirect target, not on whether rl.php is actually serving.

The site config is buggy, but the check should detect that, not paper over it (or worse, silently approve based on an unrelated 200).

Proposed changes

Ranked by impact / blast-radius:

1. Validate the response body, not just the status code

return $response->getStatusCode() === 200
    && trim((string) $response->getBody()) === 'pong';

Smallest possible change; rejects "200 from a redirected-to wrong host."

2. Disable auto-follow redirects

'allow_redirects' => false,

A redirect on a same-origin self-probe is a misconfiguration signal, not a transparent path to success. Combined with #1, the only way to get TRUE is "rl.php served at the URL we constructed, returned 'pong'."

3. Differentiate failure modes and surface them in hook_requirements

Right now every failure collapses to the same generic "rl.php is not accessible" string. Proposed: have check() return a small struct (e.g. ['status' => 'ok'|'redirected'|'http_error'|'body_mismatch'|'connection_error', 'detail' => ...]) and translate each into a specific description in rl_requirements(). Examples:

  • 3xx → "rl.php redirected to <Location>. This usually means scheme/host detection in Drupal is wrong. Check $settings['reverse_proxy'] and X-Forwarded-Proto handling."
  • non-200 → "rl.php returned HTTP <code>. The web server is not serving the file at <url>."
  • body mismatch → "rl.php served, but returned unexpected content. Another module or rewrite rule may be intercepting the request."
  • exception → "Connection failed: <message>. The web server may not be running, or DNS may be misconfigured for the public hostname."

This converts the warning from "something's wrong, look elsewhere" into a fix-this-specific-thing pointer, which is the bigger user-experience win.

4. Loopback fallback probe

After the public-URL probe fails, retry against http://127.0.0.1[:port]/... with Host: header set to $request->getHost(). If that returns "pong", the file is being served correctly by the local web server and the failure is in the proxy / scheme / redirect layer — not in Apache or PHP. Reporting that distinction is much more actionable. Also resilient against TLS-cert and DNS-from-inside-the-container classes of failure.

5. Trust X-Forwarded-Proto for this probe even if reverse_proxy isn't set

For a self-probe specifically, the module knows it's the same site — it can safely upgrade the URL to HTTPS based on X-Forwarded-Proto: https regardless of Drupal's global trusted-proxy config. That guard is too coarse for absolute-URL generation generally, but for the self-check it's fine.

$forwarded_proto = $request?->headers->get('X-Forwarded-Proto');
$scheme = in_array($forwarded_proto, ['http', 'https'], true)
    ? $forwarded_proto
    : $request?->getScheme();

6. Asymmetric cache TTLs

1h cache on success is fine; 1h on failure pins the warning long after a fix. Drop failure TTL to ~5 min (or invalidate on cache:rebuild).

7. Tighten the connection-error fallback

The current catch (\Exception $e) { return TRUE; } block (lines 78–82) is right in spirit (don't false-alarm in Docker isolation) but a bit broad. Narrow it to the cURL "couldn't resolve / couldn't connect" cases (errno 6/7); SSL errors and timeouts are real problems worth surfacing.

Suggested first PR

#1 + #2 + #3 are a small, self-contained patch with no infrastructure dependencies and they collectively turn the most common false-positive into a precise diagnostic. #4 is the next-best add but warrants its own change.

Repro on a stock setup

  1. nginx in front of Apache, proxy_set_header X-Forwarded-Proto https
  2. Drupal $settings['reverse_proxy'] not set
  3. Install rl module, visit /admin/reports/status
  4. Observe "rl.php is not accessible" even though curl https://<host>/modules/contrib/rl/rl.php -d action=ping returns pong

Files

  • src/Service/EndpointChecker.php (the probe — main change site)
  • rl.install lines 450–462 (hook_requirements — needs the structured result and richer descriptions)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions