Skip to content

Add a shell escape hatch for the IP allowlist - #35

Merged
DorwardTech merged 1 commit into
claude/zone3-darwin-internal-tool-YQKKNfrom
claude/ip-allowlist-cli
Jul 25, 2026
Merged

Add a shell escape hatch for the IP allowlist#35
DorwardTech merged 1 commit into
claude/zone3-darwin-internal-tool-YQKKNfrom
claude/ip-allowlist-cli

Conversation

@DorwardTech

Copy link
Copy Markdown
Owner

Branched off mainline rather than stacked on #34, so it can merge and deploy on its own.

Why

Every control over the IP allowlist is on a page the allowlist guards. Getting it wrong locks you out of the tool you need to fix it, and there was no way back in short of editing the database by hand.

The command

php artisan zone3:ip-allowlist          # status: enforcement + entries
php artisan zone3:ip-allowlist off      # the way back in
php artisan zone3:ip-allowlist on
php artisan zone3:ip-allowlist blocked  # which addresses were actually rejected
php artisan zone3:ip-allowlist allow 203.0.113.7 --label=Office
php artisan zone3:ip-allowlist forget 203.0.113.7

Default action is status, so a half-remembered command reports rather than changes.

blocked is the part that matters

The 403 page cannot say which address it saw, and that is nearly always the actual question.

Behind Cloudflare the address checked is CF-Connecting-IP — whichever protocol the browser used. A household with IPv6 arrives from an address that looks nothing like the IPv4 a "what is my IP" page reports, so adding the IPv4 changes nothing and gives no clue why. blocked reads the real ones out of the audit log (security.ip_blocked already records them), grouped, because a browser makes several requests per page and the raw list is one address repeated.

Care taken in the failure paths

This runs when something is already wrong, so the unhappy paths are the ones that count:

  • off verifies rather than assumes. It reads the value back through the same IpAllowlist path the middleware uses, so success means the middleware will agree — not merely that a write returned.
  • A cache flush failure is reported as "written, run cache:clear", not a stack trace over a half-applied change. The DB write is what matters; the flush is what makes it take effect.
  • off leaves the entries in place, so it is reversible without retyping the list. Nobody uses an emergency switch that costs them their configuration.
  • allow requires the address to parse, on top of the admin form's shape check. A malformed entry would sit in the table looking valid and silently never match — the same lockout with no visible cause.
  • A duplicate allow is a no-op, not a unique-constraint 500 in a shell someone is using under pressure.
  • status states that an enforced-but-empty list allows everything. It reads like total lockout and is the opposite, and that sentence is worth having in front of someone who is already worried.

Separately: ClientIp trusts CF-Connecting-IP unconditionally

Not changed here, and flagged rather than fixed because changing IP resolution during a lockout is the wrong time.

TrustCloudflareIp middleware is careful — it only rewrites REMOTE_ADDR when the connection genuinely came from a Cloudflare range. But ClientIp::from(), which is what EnforceIpAllowlist and the audit log actually call, prefers the raw CF-Connecting-IP header with no such check:

$cf = $request->headers->get('CF-Connecting-IP');
if (is_string($cf) && filter_var($cf, FILTER_VALIDATE_IP) !== false) {
    return $cf;   // trusted regardless of who sent it
}

If the Coolify origin is reachable directly by IP — bypassing Cloudflare — anyone can send that header and choose the address the allowlist checks, and the address the audit log records. It costs a defence-in-depth layer (login and TOTP still stand) and it makes audit IPs attacker-controlled, which is the part I would want fixed. The fix is to have ClientIp apply the same Cloudflare-range test the middleware already implements, but it should land on its own, with the origin firewall checked at the same time.

Tests

19 covering: off taking effect as the middleware reads it, entries surviving it, the audit trail, blocked output and its empty case, add/remove, the duplicate no-op, four malformed addresses refused, missing arguments, and the enforced-but-empty warning.

vendor/ can't be installed in this environment (codeload.github.com is blocked by the proxy), so CI is the verification.


Generated by Claude Code

Every control over the allowlist lives on a page the allowlist guards, so
getting it wrong locks you out of the tool you need to fix it. This is the
same set of operations from a shell, with no HTTP request involved:

  php artisan zone3:ip-allowlist          # status: enforcement and entries
  php artisan zone3:ip-allowlist off      # the way back in
  php artisan zone3:ip-allowlist blocked  # which addresses were rejected
  php artisan zone3:ip-allowlist allow 203.0.113.7
  php artisan zone3:ip-allowlist forget 203.0.113.7

`off` leaves the entries in place so it can be reversed without retyping
the list, verifies the change by reading it back through the same path the
middleware uses rather than trusting that a write returned, and reports a
cache flush failure as "written, run cache:clear" instead of a stack trace
over a half-applied change.

`blocked` is the diagnostic the 403 page cannot be: it lists the addresses
the app actually turned away, grouped, from the audit log. Behind
Cloudflare the address checked is CF-Connecting-IP, which is whichever
protocol the browser used — so a household with IPv6 arrives from an
address that looks nothing like the IPv4 a "what is my IP" page reports,
and adding the IPv4 changes nothing. Being able to read the real one is
the difference between fixing it and guessing.

`allow` runs the same shape check as the admin form and additionally
requires the address to parse, because a malformed entry would sit in the
table looking valid and silently never match — the same lockout with no
visible cause. A duplicate is a no-op rather than a unique-constraint 500
in a shell someone is using under pressure.

Default action is `status`, so a half-remembered command reports rather
than changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014E51bA27LnFMK7v4YigZY1
@DorwardTech
DorwardTech marked this pull request as ready for review July 25, 2026 11:05
@DorwardTech
DorwardTech merged commit b5e69c4 into claude/zone3-darwin-internal-tool-YQKKN Jul 25, 2026
2 checks passed
@DorwardTech
DorwardTech deleted the claude/ip-allowlist-cli branch August 3, 2026 00:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants