Skip to content

Rate limit the delivery API and check the read performance target - #2598

Merged
pournasserian merged 33 commits into
v2from
2583-delivery-hardening
Oct 19, 2026
Merged

pournasserian merged 33 commits into
v2from
2583-delivery-hardening

Conversation

@pournasserian

@pournasserian pournasserian commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Closes #2583

What

The last F-API (#2425) slice, on the delivery endpoints of #2563, #2571 and #2577: rate limits, input validation and the read performance target (SEC-07, PERF-03).

  • Rate limits (FluentCMS.Api): DeliveryRateLimit is a fixed-window policy per client address. It covers every delivery endpoint and /llms.txt.
    • The limit is Delivery__RateLimit__PermitLimit (600 by default) per Delivery__RateLimit__Window (one minute by default). A limit below 1, or a window that isn't positive, stops the host at startup.
    • The policy has its own rejection: a 429 problem with Retry-After and the code rate_limited. The sign-in forms keep their own policy and their plain-text answer.
    • AddFluentCmsApi now takes the configuration and registers the policy and its options. The host passes the configuration in.
    • An IPv6 client is counted by its /64 prefix, so it cannot take a fresh address for each request.
  • Trusted proxies (FluentCMS.Host):
    • ReverseProxy__KnownProxies__0 and ReverseProxy__KnownNetworks__0 list the proxies whose X-Forwarded-For names the client, before the rate limiters run. This covers the sign-in limit too.
    • With none listed, the header is ignored, so no client can choose the address it is counted by.
    • An entry that is not an address or a network stops the host.
  • Input validation: a content type in the path that cannot be a slug is a 404 problem, without a database query. Every query parameter is parsed before any read (Serve published entries and entities over a read-only HTTP API #2563). The host's nosniff and content security policy headers are now tested on delivery responses.
  • PERF-03: a test, running alone in its own collection, loads 10,000 published entries: 50 products and pages that resolve their price. It reads 200 random resolved pages through IPublishedContent, after 10 warm-up reads, and asserts the p95 is under 100 ms on SQLite and on PostgreSQL. No new index is needed: reads use the entry primary key, versions by entry and version, and references by entry and version.
  • Docs: CONTRIBUTING lists the rate limit and proxy settings. docs/api/delivery.md has a Limits section, and its error table has rate_limited.

Why

SEC-07 asks for rate limits and input validation on the delivery API. The API is anonymous, so a per-address limit is what keeps one client from exhausting the host. PERF-03 sets the bar for a single resolved entry, and the test keeps it measured on both providers, so a regression shows up in CI.

Choices worth a look:

  • Rows are seeded directly for the 10,000 entries, as the directory stores them. Loading them through the directory would take minutes, and this test measures reads, not writes.
  • CORS and response caching are out of scope, as Harden the delivery API with rate limits and the read performance target #2583 says. Server-rendered frontends need neither. A correct ETag would also have to include the published versions of the referenced entities.

Tests

  • Over HTTP through the host:
    • With a limit of 3, the fourth delivery request gets a 429 problem with Retry-After and rate_limited. /llms.txt shares the limit, and /health stays reachable.
    • Two client addresses fall into different partitions. IPv6 addresses in one /64 share a partition, and an IPv4-mapped address counts as its IPv4 address.
    • Behind a trusted proxy, six clients named by X-Forwarded-For each get their own limit. Without a trusted proxy, the header is ignored and the fourth request is refused.
    • A proxy entry that is not an address, or a network that is not one, stops the host.
    • Through the endpoints alone, a type that cannot be a slug is a 404 without the reader being asked.
    • Product-Page, product_page and a b as a type are 404 problems that carry nosniff and the content security policy.
    • A permit limit of 0 stops the host from starting, and the message names the setting.
  • PERF-03 on SQLite and PostgreSQL, as described above.

The full suite ran locally against a postgres:17 container as well as SQLite, and markdownlint is clean. There is no migration and no new package.

Review

An independent review blocked this PR. Behind a reverse proxy, every client shared the proxy's address. Honouring X-Forwarded-For from anyone would have let a client pick a fresh bucket. Both are fixed with trusted proxies from configuration. Its other findings are fixed too:

  • IPv6 clients are grouped by /64;
  • the slug check is tested before any read;
  • the p95 test now runs in a non-parallel collection, so the rest of the suite does not load the machine while it measures.

A second review gave MERGE. Its Low finding is fixed in the docs, which now say that one forwarded hop is read and how to set up a proxy chain. The sign-in limit's IPv6 grouping, which was already there before this PR, is tracked in #2640.

Checklist

  • Linked to an issue a maintainer has confirmed
  • Targets v2
  • Tests or docs updated where behaviour changed
  • No tool-generated co-author trailers or footers

# Conflicts:
#	docs/api/delivery.md
#	src/FluentCMS.Api/DeliveryEndpoints.cs
#	src/FluentCMS.Core/Delivery/IPublishedContent.cs
#	src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs
#	tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs
#	tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs
# Conflicts:
#	CONTRIBUTING.md
#	src/FluentCMS.Api/DeliveryEndpoints.cs
#	src/FluentCMS.Core/Delivery/IPublishedContent.cs
#	tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs
# Conflicts:
#	src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs
# Conflicts:
#	CONTRIBUTING.md
#	docs/api/delivery.md
@pournasserian pournasserian added this to the M1 Governance core milestone Oct 19, 2026
@pournasserian pournasserian added type:feature New capability v2 FluentCMS v2 (v2 branch) area:api Delivery API (API) labels Oct 19, 2026
@pournasserian
pournasserian merged commit bcb6e47 into v2 Oct 19, 2026
2 checks passed
@pournasserian
pournasserian deleted the 2583-delivery-hardening branch October 19, 2026 11:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:api Delivery API (API) type:feature New capability v2 FluentCMS v2 (v2 branch)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Harden the delivery API with rate limits and the read performance target

1 participant