Skip to content

Default-block user-generated content paths on claude.ai #316

@schmitthub

Description

@schmitthub

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/xthe 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:1032buildHTTPRoutes (the route-order bug lives here)
  • internal/controlplane/firewall/rules_store.go:160EffectivePathDefault semantics + MergeRule
  • internal/config/schema.go:134PathRule schema
  • CLAUDE.md "How rules are managed" (egress merge semantics)

Metadata

Metadata

Assignees

No one assigned

    Labels

    securitySecurity hardening or fixes

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions