Skip to content

fix(api): stop letting userId partition the public flag rate limit - #717

Merged
izadoesdev merged 1 commit into
stagingfrom
izadoesdev/trusted-ip-flag-rate-limit
Sep 3, 2026
Merged

fix(api): stop letting userId partition the public flag rate limit#717
izadoesdev merged 1 commit into
stagingfrom
izadoesdev/trusted-ip-flag-rate-limit

Conversation

@izadoesdev

@izadoesdev izadoesdev commented Sep 3, 2026

Copy link
Copy Markdown
Member

Closes the P1 cubic raised on the release PR (#714) for apps/api/src/routes/public/flags.ts.

The bug

enforcePublicFlagRateLimit built its bucket key from ip || userId:

const visitor = ip || userId || "";
await ratelimit(`flags:eval:${clientId || "anon"}:${visitor || "shared"}`, 600, 60);

userId is read straight off the query string or body. Any caller without an IP header could invent a new value per request and mint a fresh 600/min bucket every time, so the limit did nothing.

The fix

Drop userId from the limiter. The key is the client IP, falling back to one shared bucket when there is no IP:

const ip = getClientIp(request.headers);
await ratelimit(`flags:eval:${clientId || "anon"}:${ip ?? "shared"}`, 600, 60);

Both call sites stop passing userId, and flag_rate_limit_scope collapses to ip / shared now that there is no third case.

Net: one file, +4/−26.

Scope

Deliberately narrow. An earlier revision of this branch added a Cloudflare-only IP reader in packages/shared plus a SELFHOST-gated aggregate ceiling. Greptile then pointed out the obvious hole — a directly-exposed origin can set cf-connecting-ip too, so the ceiling was skipped exactly where it was needed. That design also reintroduced IP-trust configuration that a18d88bf0 had just removed on purpose. It has been dropped entirely; packages/shared is untouched by this PR.

Header spoofing against getClientIp remains possible off the edge. That is a property of all 18 of its call sites, not this endpoint, and is not something to fix with bespoke machinery here.

clientId rotation also still mints buckets. That predates this change and is inherent to keying a public endpoint on a public identifier.

Verification

bun run check-types 33/33 and bun run test 27/27, both re-run with --force to bypass the turbo cache. bun run lint clean, 14/14 policy tests.

The Test failure on the previous push was a CI flake: exit code 130 (SIGINT) with every visible test passing. packages/rpc's real test script gives 149 pass / 0 fail identically on this branch and on staging, and the job passed on re-run.

@vercel

vercel Bot commented Sep 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
databuddy-status Ready Ready Preview Sep 3, 2026 8:19pm UTC
2 Skipped Deployments
Project Deployment Actions Updated
dashboard Skipped Skipped Sep 3, 2026 8:19pm UTC
documentation Skipped Skipped Sep 3, 2026 8:19pm UTC

@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Team

Run ID: d909b421-b332-4eed-95ad-513a663bb94b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@unkey-deploy

unkey-deploy Bot commented Sep 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Unkey Deploy

Name Status Preview Inspect Updated (UTC)
links (preview) Ready Visit Preview Inspect Sep 3, 2026 8:18pm

@greptile-apps

greptile-apps Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a Cloudflare-only client-IP reader and uses it to place direct public flag requests under an aggregate per-client rate limit.

  • Adds getTrustedClientIp and tests that exclude fallback forwarding headers.
  • Applies per-visitor and aggregate rate-limit checks to public flag evaluation.
  • The trusted-header assumption remains unsafe for directly exposed self-hosted APIs.

Confidence Score: 3/5

This PR is not safe to merge until direct-origin requests cannot use a caller-supplied Cloudflare header to bypass the aggregate flag-evaluation limit.

The new ceiling is skipped whenever cf-connecting-ip is present, while the supported self-hosted deployment exposes the API directly and has no middleware that authenticates or sanitizes that header.

Files Needing Attention: apps/api/src/routes/public/flags.ts, packages/shared/src/utils/client-ip.ts

Security Review

Direct-origin callers can spoof and rotate cf-connecting-ip, causing the limiter to classify them as trusted and skip the new aggregate ceiling. How this was verified: The self-hosted API exposes its port directly, no middleware sanitizes the header, and any nonempty value suppresses the aggregate check.

Important Files Changed

Filename Overview
apps/api/src/routes/public/flags.ts Adds the aggregate limiter, but header presence alone lets direct-origin callers bypass it with spoofed identities.
packages/shared/src/utils/client-ip.ts Adds a focused Cloudflare-header reader whose trust guarantee depends on an unenforced proxy boundary.
packages/shared/src/utils/client-ip.test.ts Covers header selection correctly but does not cover direct-origin callers supplying the nominally trusted header.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  Request[Public flag request] --> Trusted{cf-connecting-ip present?}
  Trusted -->|Yes| Visitor[Per-IP bucket: 600/min]
  Visitor --> Evaluate[Evaluate flag]
  Trusted -->|No| User[Per-user/shared bucket: 600/min]
  User --> Aggregate[Per-client aggregate: 6000/min]
  Aggregate --> Evaluate
  Direct[Direct-origin attacker] --> Spoof[Spoof rotating cf-connecting-ip]
  Spoof --> Trusted
Loading

Reviews (1): Last reviewed commit: "fix(api,shared): bound the public flag l..." | Re-trigger Greptile

Comment thread apps/api/src/routes/public/flags.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files

Shadow auto-approve: would not auto-approve because issues were found.

Re-trigger cubic

Comment thread packages/shared/src/utils/client-ip.ts Outdated
Comment thread apps/api/src/routes/public/flags.ts Outdated
enforcePublicFlagRateLimit keyed on `ip || userId`, and userId comes
straight off the query string, so a caller with no ip header minted a
fresh 600/min bucket per value it invented. Drop it: the limit keys on
the client ip, and falls back to one shared bucket when there is none.

Callers no longer pass userId, and the scope field collapses to ip or
shared now that there is no third case to report.
@izadoesdev
izadoesdev force-pushed the izadoesdev/trusted-ip-flag-rate-limit branch from f0d007b to e237fc3 Compare September 3, 2026 20:18
@izadoesdev izadoesdev changed the title fix(api,shared): bound the public flag limit when no trusted ip exists fix(api): stop letting userId partition the public flag rate limit Sep 3, 2026
@vercel
vercel Bot temporarily deployed to Preview – documentation September 3, 2026 20:18 Inactive
@vercel
vercel Bot temporarily deployed to Preview – dashboard September 3, 2026 20:18 Inactive
@izadoesdev
izadoesdev merged commit 5801a87 into staging Sep 3, 2026
20 checks passed
@izadoesdev
izadoesdev deleted the izadoesdev/trusted-ip-flag-rate-limit branch September 3, 2026 20:37
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.

1 participant