Skip to content

/subscribe stores any https endpoint from an anonymous caller — #13's "no client input reaches the outbound fetch" does not hold on the push path #75

Description

@bdelanghe

Cold read of the live site, 2026-09-03.

Disclosure first

Establishing this required a write, so I made one. There is now a junk record in the production SUBSCRIPTIONS namespace pointing at https://example.invalid/x. Its key is sub: + SHA-256 of that endpoint. It has no TTL and nothing prunes it, which is part of the finding. Please drain it.

What happens

$ curl -X POST -H 'content-type: application/json' \
    -d '{"endpoint":"https://example.invalid/x","keys":{"p256dh":"x","auth":"y"}}' \
    https://desk.bounded.tools/subscribe
201
{"ok":true}

No session, no origin check, and no proof that the endpoint is a push service.

Why this is #13's invariant, in a path #13 did not survey

#13 concludes, for the feed read: "No client input reaches the outbound fetch. The URL comes from env.FEED_URL / env.PRS_FEED_URL — configuration, never the request." That is correct. I confirmed it in src/worker.js.

It is not true of the push fan-out. src/push.js builds its request from the stored endpoint:

export function audienceFor(endpoint) {
  return new URL(endpoint).origin;
}

So an anonymous POST to /subscribe chooses a host the Worker will later fetch, and audienceFor mints a VAPID JWT whose aud is the caller's own origin, signed with VAPID_PRIVATE_KEY and then delivered to them. #13 called the steerable-fetch case "SSRF, not just amplification", then established that the property held three times over — for the path it looked at. There are two outbound paths and it surveyed one.

The check that is claimed vs the check that runs

src/subscriptions.js is explicit about the property it is defending:

// https only. A push service is always https, and accepting anything else
// would let a subscribe call point this Worker's sender at an arbitrary host.
if (url.protocol !== "https:") return { ok: false, error: "endpoint must be https" };

The comment names the right risk. The check stops one step short of it: it constrains the scheme, not the host. It excludes http://attacker and admits https://attacker. This is the shape docs/agentic-code-hygiene.md rule 3 is about — a gate's own claim about itself is not evidence. The rationale is sound; the code does not implement it.

Second-order: the store only grows

putSubscription writes with no expirationTtl, and per src/push.js only a 404 or 410 from the push service is allowed to delete. A hostile or merely inert endpoint returns neither, so a record written this way is permanent. Distinct endpoints hash to distinct keys, so the dedupe-by-hash design — correct for real devices — is exactly what makes anonymous writes unbounded. Every subsequent fan-out pays for every junk record.

Proposed

  1. Allowlist push-service hosts. The real set is small and stable (*.push.services.mozilla.com, *.notify.windows.com, fcm.googleapis.com, *.push.apple.com). This is the check the existing comment already describes, and it restores Pin the invariant that a client cannot cache-bust to origin, or steer the Worker's outbound fetch #13's invariant across both outbound paths.
  2. Give stored subscriptions a TTL, refreshed on re-subscribe, so an endpoint that never 410s ages out instead of accumulating.
  3. Pin it, in Pin the invariant that a client cannot cache-bust to origin, or steer the Worker's outbound fetch #13's spirit: drive /subscribe with adversarial endpoints and assert that nothing outside the allowlist is ever stored or fetched. Pin the invariant that a client cannot cache-bust to origin, or steer the Worker's outbound fetch #13 asks for that pin on the feed path; this is the same pin on the push path.

Not measured

Whether Cloudflare rate-limits this route in front of the Worker. That would bound the write rate but not the property.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions