Problem
.claude.ai is on the default allow list (internal/config/defaults.go:20) at host scope, no path filter. That permits Claude Code's OAuth + downloads, but it also opens the UGC surface — anyone can paste an agent a link to attacker-controlled content hosted on claude.ai.
Example UGC URL (just discovered this is allowed by default):
https://claude.ai/public/artifacts/0480fc7d-db0c-4093-a58d-1327f94b143d
Anyone with an Anthropic account can publish HTML/JS/markdown at /public/artifacts/* and /share/*. From the firewall's POV that's same-origin as the legitimate OAuth surface, so the existing claude.ai allow-rule lets a prompt-injection payload pivot an agent into fetching attacker-authored content from a trusted domain. Allow-list bypass via UGC on a trusted CDN.
Proposal
Use the existing path-rule machinery (config.PathRule / PathDefault, EffectivePathDefault in internal/controlplane/firewall/rules_store.go) to scope the default .claude.ai rule.
Sketch — internal/config/defaults.go:
{
Dst: ".claude.ai", Proto: "https", Port: 443, Action: "allow",
PathRules: []PathRule{
{Path: "/public/", Action: "deny"},
{Path: "/share/", Action: "deny"},
},
// PathDefault left empty → EffectivePathDefault returns "allow"
// since all explicit PathRules are deny (denylist mode).
},
Net effect: OAuth + login flows keep working; agent-initiated fetches to known UGC prefixes get denied at Envoy by default. Users who legitimately need a UGC path can still clawker firewall add claude.ai --path /public/ --action allow.
BLOCKER — longest-prefix-wins is broken, must fix before shipping the default deny
The override story has two paths today. Only one of them actually works, and the broken one is exactly the case a user with their own UGC URL will hit:
1. Exact path-string override — works
MergeRule in rules_store.go keys PathRules by exact Path string. User's clawker firewall add claude.ai --path /public/ --action allow collides with the default's /public/ deny → caller wins → override applies cleanly.
2. Narrower-prefix override — silently broken
User's clawker firewall add claude.ai --path /public/myproject/ --action allow is a different Path key, so both entries survive the merge. Then buildHTTPRoutes in internal/controlplane/firewall/envoy_config.go:1032 iterates r.PathRules in slice insertion order and emits Envoy match: {prefix: ...} routes in that order. Envoy is first-match-wins on prefix.
The default's broader /public/ deny was appended first by the bootstrap reseed → matches /public/myproject/x → the user's narrower allow rule is unreachable. The route emit log will show both rules present and the operator will reasonably assume the longer/more-specific one wins. It doesn't.
This violates the documented intent: longest-prefix-wins / "most specific rule wins" — same model as Cloudflare WAF path rules, nginx location blocks, API Gateway resource policies. Every comparable allowlist-with-path-overrides product behaves this way.
Fix — sort PathRules longest-prefix-first before emitting routes
One-liner in buildHTTPRoutes (or upstream of it in the closure that builds the HCM route table). Stable sort by len(Path) descending so insertion order is preserved as a tiebreaker for equal-length prefixes:
// in buildHTTPRoutes, before the `for _, pr := range r.PathRules` loop:
rules := slices.Clone(r.PathRules)
sort.SliceStable(rules, func(i, j int) bool {
return len(rules[i].Path) > len(rules[j].Path)
})
for _, pr := range rules { ... }
No schema change, no CLI surface change, no rules-store semantics change. The merge layer's exact-path override (mode 1) keeps working because identical len(Path) falls back to insertion order, and caller-wins on collision still resolves identical paths before sorting matters.
Regression test belongs in envoy_config_test.go — assert that given [{Path: "/", deny}, {Path: "/api/", allow}, {Path: "/api/v1/internal/", deny}], the emitted routes are ordered [/api/v1/internal/ deny, /api/ allow, / deny].
This bug is latent today because no shipped default uses overlapping path prefixes — every requiredFirewallRules entry is host-scope only. The moment we add the .claude.ai UGC deny defaults proposed above, the bug becomes user-visible and exploitable as a footgun. Fix the ordering first, ship the defaults second.
Open questions
- Confirm the full set of UGC path prefixes on
claude.ai (at minimum /public/, /share/ — are there others?).
- Decide denylist vs allowlist posture: deny known UGC prefixes (above) vs. flip to
path_default: deny and explicitly allow the OAuth paths. Denylist is less likely to break login flows the next time Anthropic adds a route; allowlist is the stricter posture. With longest-prefix-wins fixed, allowlist posture becomes ergonomically viable because users can override at any depth.
- E2E coverage: add a
test/e2e/ case asserting a GET to https://claude.ai/public/artifacts/<uuid> from an agent container returns connection-reset under default config, AND a second case asserting a narrower user override (firewall add claude.ai --path /public/myproject/ --action allow) actually takes effect.
Related
internal/config/defaults.go — required rule list
internal/controlplane/firewall/envoy_config.go:1032 — buildHTTPRoutes (the route-order bug lives here)
internal/controlplane/firewall/rules_store.go:160 — EffectivePathDefault semantics + MergeRule
internal/config/schema.go:134 — PathRule schema
- CLAUDE.md "How rules are managed" (egress merge semantics)
Problem
.claude.aiis on the default allow list (internal/config/defaults.go:20) at host scope, no path filter. That permits Claude Code's OAuth + downloads, but it also opens the UGC surface — anyone can paste an agent a link to attacker-controlled content hosted onclaude.ai.Example UGC URL (just discovered this is allowed by default):
Anyone with an Anthropic account can publish HTML/JS/markdown at
/public/artifacts/*and/share/*. From the firewall's POV that's same-origin as the legitimate OAuth surface, so the existingclaude.aiallow-rule lets a prompt-injection payload pivot an agent into fetching attacker-authored content from a trusted domain. Allow-list bypass via UGC on a trusted CDN.Proposal
Use the existing path-rule machinery (
config.PathRule/PathDefault,EffectivePathDefaultininternal/controlplane/firewall/rules_store.go) to scope the default.claude.airule.Sketch —
internal/config/defaults.go:{ Dst: ".claude.ai", Proto: "https", Port: 443, Action: "allow", PathRules: []PathRule{ {Path: "/public/", Action: "deny"}, {Path: "/share/", Action: "deny"}, }, // PathDefault left empty → EffectivePathDefault returns "allow" // since all explicit PathRules are deny (denylist mode). },Net effect: OAuth + login flows keep working; agent-initiated fetches to known UGC prefixes get denied at Envoy by default. Users who legitimately need a UGC path can still
clawker firewall add claude.ai --path /public/ --action allow.BLOCKER — longest-prefix-wins is broken, must fix before shipping the default deny
The override story has two paths today. Only one of them actually works, and the broken one is exactly the case a user with their own UGC URL will hit:
1. Exact path-string override — works
MergeRuleinrules_store.gokeysPathRulesby exactPathstring. User'sclawker firewall add claude.ai --path /public/ --action allowcollides with the default's/public/ deny→ caller wins → override applies cleanly.2. Narrower-prefix override — silently broken
User's
clawker firewall add claude.ai --path /public/myproject/ --action allowis a differentPathkey, so both entries survive the merge. ThenbuildHTTPRoutesininternal/controlplane/firewall/envoy_config.go:1032iteratesr.PathRulesin slice insertion order and emits Envoymatch: {prefix: ...}routes in that order. Envoy is first-match-wins on prefix.The default's broader
/public/deny was appended first by the bootstrap reseed → matches/public/myproject/x→ the user's narrower allow rule is unreachable. The route emit log will show both rules present and the operator will reasonably assume the longer/more-specific one wins. It doesn't.This violates the documented intent: longest-prefix-wins / "most specific rule wins" — same model as Cloudflare WAF path rules, nginx
locationblocks, API Gateway resource policies. Every comparable allowlist-with-path-overrides product behaves this way.Fix — sort PathRules longest-prefix-first before emitting routes
One-liner in
buildHTTPRoutes(or upstream of it in the closure that builds the HCM route table). Stable sort bylen(Path)descending so insertion order is preserved as a tiebreaker for equal-length prefixes:No schema change, no CLI surface change, no rules-store semantics change. The merge layer's exact-path override (mode 1) keeps working because identical
len(Path)falls back to insertion order, and caller-wins on collision still resolves identical paths before sorting matters.Regression test belongs in
envoy_config_test.go— assert that given[{Path: "/", deny}, {Path: "/api/", allow}, {Path: "/api/v1/internal/", deny}], the emitted routes are ordered[/api/v1/internal/ deny, /api/ allow, / deny].This bug is latent today because no shipped default uses overlapping path prefixes — every
requiredFirewallRulesentry is host-scope only. The moment we add the.claude.aiUGC deny defaults proposed above, the bug becomes user-visible and exploitable as a footgun. Fix the ordering first, ship the defaults second.Open questions
claude.ai(at minimum/public/,/share/— are there others?).path_default: denyand explicitly allow the OAuth paths. Denylist is less likely to break login flows the next time Anthropic adds a route; allowlist is the stricter posture. With longest-prefix-wins fixed, allowlist posture becomes ergonomically viable because users can override at any depth.test/e2e/case asserting a GET tohttps://claude.ai/public/artifacts/<uuid>from an agent container returns connection-reset under default config, AND a second case asserting a narrower user override (firewall add claude.ai --path /public/myproject/ --action allow) actually takes effect.Related
internal/config/defaults.go— required rule listinternal/controlplane/firewall/envoy_config.go:1032—buildHTTPRoutes(the route-order bug lives here)internal/controlplane/firewall/rules_store.go:160—EffectivePathDefaultsemantics +MergeRuleinternal/config/schema.go:134—PathRuleschema