From faf56c260d52c195b9a1f2b787ee512a8293142c Mon Sep 17 00:00:00 2001 From: Hugh Grigg Date: Sat, 29 Aug 2026 14:18:48 +0100 Subject: [PATCH 1/2] docs: say what abusing the collection path costs The beacon's path takes a GET from anybody and counts it, and docs/ said nothing about what that costs. Poisoned counts recover, because raw is immutable and every rollup is rebuilt from it, and #104 carries the filter that rebuilds them. The money does not recover, and that is the half the page has to be honest about on a pipeline priced in cents. Layer 1 is open the same way and always was. A million requests for a real page inflate the pageview count, so the page says that before it says anything about the beacon. WAF prices are written down with the date they were read, along with why $6 a month of fixed floor against the $0.084 a month #9 measured stays out of the default path. No WAF construct ships, since that is a site's decision. A budget alarm is what the page recommends instead. --- docs/README.md | 5 ++ docs/abuse/README.md | 122 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 docs/abuse/README.md diff --git a/docs/README.md b/docs/README.md index 50b3310..9eb199b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,3 +25,8 @@ runs on every `pnpm check`. - [Query](query/), running SQL against the log table with `rainlytics query`. - [Rollup summaries](summaries/), the schema for the precomputed answers the commands read. - [Counting visitors](visitors/), what a visitor count means and over what window. + +## Cost + +- [Abusing the collection path](abuse/), what an open collection path exposes, and the prices for + containing it. diff --git a/docs/abuse/README.md b/docs/abuse/README.md new file mode 100644 index 0000000..3242723 --- /dev/null +++ b/docs/abuse/README.md @@ -0,0 +1,122 @@ +# What abusing the collection path costs + +The beacon reports to a path on the site's own domain, and CloudFront records the request in the +access log like any other. The path is open and unauthenticated. Anybody can send that URL a +million times and have every one of them counted, carrying a page value naming a page nobody opened and an event that +never happened. + +Two things follow, and they want different answers. The counts recover. The money is spent. + +## Layer 1 is open in the same way + +This comes first because the beacon looks like the thing that opened the door. + +A site's own pages take a request from anybody. A million requests for a real page put a million +rows in the log, and the pageview count follows them up. Every analytics product built on server +logs works this way. A log records what arrived and has no way to ask why. + +What layer 2 adds is a forged page value and events nobody caused. The gap is narrower than it +looks. A spammed page request already lies about which page was read, and it transfers the page body +to do it. A spammed beacon request carries no body in either direction. + +## The counts recover + +The raw store is immutable and every rollup is rebuilt from it. A poisoned window is a re-run under +a better filter. + +[#104](https://github.com/KensioSoftware/rainlytics/issues/104) carries that filter. It belongs in +the rollup query, beside the [crawler filter](../rollups/#crawlers-are-most-of-the-traffic) every +question already applies. The raw store keeps every row and the query decides what to count. A rule +that turns out to be wrong is another re-run. + +The [log bucket's](../log-bucket/) expiry is the outer limit on this. A window that has aged past it +has no rows left to recount, under any filter at all. A year is the default. + +## The money is spent + +A re-run fixes a number. Nothing re-runs a bill. Every spammed request buys three charges, and the +site pays all three whatever a rollup later decides about the row. + +**A CloudFront request.** The distribution charges per request at its own rate, and that charge +lands on the CDN bill whether Rainlytics is installed or not. A request for a real page costs the +same and transfers a page body on top of it. Whatever answers the collection path is priced per hit +too, and [#99](https://github.com/KensioSoftware/rainlytics/issues/99) is choosing between a +CloudFront Function and a small cached object on those terms. + +**A log record, kept for the bucket's retention.** +[#9](https://github.com/KensioSoftware/rainlytics/issues/9) measured the whole pipeline at $0.084 a +month on a site serving 137,000 requests a day, which works out near $0.02 per million requests. It +splits between one PUT per delivered object and steady-state storage under the 365-day expiry. A +flood pays that on the way in and then pays the storage every month until the expiry drops it. + +**Bytes that every query over the window scans.** Athena bills $5.00 per terabyte. Spammed rows sit +in the same objects as real ones and no partition predicate tells them apart. Every rollup covering +the window reads them once per run, for as long as that window stays in range. + +The third charge already has a ceiling. The [query workgroup's](../query-workgroup/) +bytes-scanned cutoff fails a query at ten gibibytes, which caps one query near five cents whatever +the flood put in the window. The first two have no ceiling. + +## AWS WAF, and why it stays out of the default + +WAF is the one place at the edge where a request count can be kept, and it is priced in the open. +Read from the AWS WAF pricing page on 2026-08-29: + +- $5.00 a month per web ACL +- $1.00 a month per rule +- $0.60 per million requests inspected + +A rate-based rule is an ordinary rule at $1.00. So the smallest configuration that would help, one +web ACL carrying one rate-based rule on the collection path, is $6.00 a month before a single request +reaches it. + +Set that beside the $0.084 a month #9 measured. WAF is a fixed floor around seventy times the +pipeline it protects, and it is billed in full in a quiet month when nobody attacks anything. Every +other charge on this page is priced by use, and this would be the largest line on a quiet site's +bill. + +That answer flips for a site already running a web ACL for other reasons. The $5.00 is paid, the +rule is $1.00, and the collection path joins something that exists. The default is for a site +installing Rainlytics, where the ACL would exist for this alone. + +## Why the count has to live in WAF + +Rate limiting needs a count that survives between requests, and the edge has nowhere to keep one. + +- **CloudFront Functions** hold no state between invocations. A function sees one request and + forgets it. +- **CloudFront KeyValueStore** is read-only from function code. A function reads what a deploy put + there and cannot write a counter back. +- **Shield Standard** comes at no charge and works at the network layer. Ten well-formed HTTPS + requests a second look like traffic to it. +- **Shield Advanced** carries the application-layer protection and costs $3,000 a month. + +## A budget alarm is the honest answer + +An exposure that outlasts every attempt to prevent it is one to be told about. AWS Budgets gives an +account its first two budgets at no charge, and a cost alarm is one of them. + +Put one on the account carrying the distribution and the log bucket, with a threshold above what a +quiet month costs (#9's figure is the right shape for a site of that size, and a month of real +billing is better). An alert firing at twice a normal month is a flood in progress. The decision +about WAF is then taken with a bill in hand, which beats guessing at one during a deploy. + +## No WAF construct ships here + +Every resource Rainlytics creates is priced by use, and a construct putting $6.00 a month into the +default path would break that for every site installing it. Whether the $6.00 is worth paying +depends on what a site is worth attacking, what else its account already runs, and what its owner +wants to spend. That is the site's decision, and it is taken with information the library lacks. + +A site taking it writes the web ACL in its own CDK app and associates it with the distribution. The +collection path is `/_rainlytics` unless a site names another, and it is exported as +`defaultBeaconPath` from the package root, so a rate-based rule can scope itself to the same path +the beacon reports to. + + From 650871f4141e848f14e7aca3bd663b6dc9f1a380 Mon Sep 17 00:00:00 2001 From: Hugh Grigg Date: Sat, 29 Aug 2026 16:20:18 +0100 Subject: [PATCH 2/2] docs: tighten what the abuse page claims about cost and filtering Five corrections from review. #104's filter is described as still to be chosen, since no rollup applies one. The Athena charge is separated from the other two, because it arrives only when a rollup or a --query run reads the poisoned window, and the ten gibibyte cutoff is scoped to queries naming the workgroup, with Athena's own primary left uncapped. What #9 measured is called the log store rather than the whole pipeline, and the page says why that was the whole of Rainlytics' own bill on that site. Layer 1's exposure now names the three conditions a pageview row has to pass and says the crawler filter catches nothing but a flood that admits to being one. --- docs/abuse/README.md | 55 ++++++++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/docs/abuse/README.md b/docs/abuse/README.md index 3242723..ea6c207 100644 --- a/docs/abuse/README.md +++ b/docs/abuse/README.md @@ -12,8 +12,11 @@ Two things follow, and they want different answers. The counts recover. The mone This comes first because the beacon looks like the thing that opened the door. A site's own pages take a request from anybody. A million requests for a real page put a million -rows in the log, and the pageview count follows them up. Every analytics product built on server -logs works this way. A log records what arrived and has no way to ask why. +rows in the log. Each one is a GET that answered HTML and succeeded, and that is the whole of what +`pageviews` asks of a row, so the count follows the flood up. The [crawler +filter](../rollups/#crawlers-are-most-of-the-traffic) catches a flood naming itself a bot and +nothing else about it. Every analytics product built on server logs works this way. A log records +what arrived and has no way to ask why. What layer 2 adds is a forged page value and events nobody caused. The gap is narrower than it looks. A spammed page request already lies about which page was read, and it transfers the page body @@ -24,18 +27,18 @@ to do it. A spammed beacon request carries no body in either direction. The raw store is immutable and every rollup is rebuilt from it. A poisoned window is a re-run under a better filter. -[#104](https://github.com/KensioSoftware/rainlytics/issues/104) carries that filter. It belongs in -the rollup query, beside the [crawler filter](../rollups/#crawlers-are-most-of-the-traffic) every -question already applies. The raw store keeps every row and the query decides what to count. A rule -that turns out to be wrong is another re-run. +[#104](https://github.com/KensioSoftware/rainlytics/issues/104) is where that filter gets chosen +and written, and no rollup applies one yet. It belongs in the rollup query, beside the crawler +filter every question already applies. The raw store keeps every row and the query decides what to +count. A rule that turns out to be wrong is another re-run. The [log bucket's](../log-bucket/) expiry is the outer limit on this. A window that has aged past it has no rows left to recount, under any filter at all. A year is the default. ## The money is spent -A re-run fixes a number. Nothing re-runs a bill. Every spammed request buys three charges, and the -site pays all three whatever a rollup later decides about the row. +A re-run fixes a number. Nothing re-runs a bill. Every spammed request buys two charges outright +and arms a third, and no filter written afterwards takes any of them back. **A CloudFront request.** The distribution charges per request at its own rate, and that charge lands on the CDN bill whether Rainlytics is installed or not. A request for a real page costs the @@ -44,18 +47,24 @@ too, and [#99](https://github.com/KensioSoftware/rainlytics/issues/99) is choosi CloudFront Function and a small cached object on those terms. **A log record, kept for the bucket's retention.** -[#9](https://github.com/KensioSoftware/rainlytics/issues/9) measured the whole pipeline at $0.084 a -month on a site serving 137,000 requests a day, which works out near $0.02 per million requests. It -splits between one PUT per delivered object and steady-state storage under the 365-day expiry. A -flood pays that on the way in and then pays the storage every month until the expiry drops it. - -**Bytes that every query over the window scans.** Athena bills $5.00 per terabyte. Spammed rows sit -in the same objects as real ones and no partition predicate tells them apart. Every rollup covering -the window reads them once per run, for as long as that window stays in range. - -The third charge already has a ceiling. The [query workgroup's](../query-workgroup/) -bytes-scanned cutoff fails a query at ten gibibytes, which caps one query near five cents whatever -the flood put in the window. The first two have no ceiling. +[#9](https://github.com/KensioSoftware/rainlytics/issues/9) measured the log store at $0.084 a month +on a site serving 137,000 requests a day, which works out near $0.02 per million requests. It splits +between one PUT per delivered object and steady-state storage under the 365-day expiry. CloudFront +delivers into the bucket at no charge, which made that figure the whole of what Rainlytics itself +cost on that site. A flood pays the rate on the way in and then pays the storage every month until +the expiry drops it. + +**Bytes that a query over the window scans.** This is the armed one. Athena bills $5.00 per terabyte, +and the charge arrives only when something reads the window (a scheduled rollup, or a `--query` run +for a fresher answer). Spammed rows sit in the same objects as real ones and no partition predicate +tells them apart, so each run that covers the window reads them again for as long as it stays in +range. + +That third charge already has a ceiling. The [query workgroup's](../query-workgroup/) bytes-scanned +cutoff fails a query at ten gibibytes, which caps one query near five cents whatever the flood put in +the window. It binds queries naming the workgroup, being `rainlytics` unless a deployment renamed it. +Athena's own `primary` workgroup has no cutoff, and a query landing there is uncapped. The first two +charges have no ceiling anywhere. ## AWS WAF, and why it stays out of the default @@ -70,8 +79,8 @@ A rate-based rule is an ordinary rule at $1.00. So the smallest configuration th web ACL carrying one rate-based rule on the collection path, is $6.00 a month before a single request reaches it. -Set that beside the $0.084 a month #9 measured. WAF is a fixed floor around seventy times the -pipeline it protects, and it is billed in full in a quiet month when nobody attacks anything. Every +Set that beside the $0.084 a month #9 measured. WAF is a fixed floor around seventy times the log +store it protects, and it is billed in full in a quiet month when nobody attacks anything. Every other charge on this page is priced by use, and this would be the largest line on a quiet site's bill. @@ -116,7 +125,7 @@ the beacon reports to.