fix: pre-flight the CloudFront payload limits the dry run is the only guard for
Build and deploy / build-and-deploy (push) Failing after 4s
Build and deploy / build-and-deploy (push) Failing after 4s
The second `--apply` of 2026-09-04 created adr-sml-pdf-noindex and then failed at create-origin-request-policy: InvalidArgument, "The parameter Comment is too big" — 182 characters against a 128 cap. update-distribution never ran, so the distribution is unchanged, but the account was left holding an orphaned policy. Nothing local could have caught it, and that is now measured rather than assumed: botocore/validate.py checks neither `max` nor `pattern` (range_check reads only `min`; the word `pattern` does not appear in the file), and the 128 is not modelled as a constraint at all — `Comment` is a bare `string` and the cap lives in the shape's documentation prose. So the dry run really is the only pre-flight, and it now enforces PAYLOAD_LIMITS: 13 entries across both policy payloads and the function ARN, each with the source it came from. The entries that matter guard CLONED values rather than literals this file authors — a literal is reviewed when it is written, while a value copied out of the default behaviour's policy changes with no diff here. The API declares TooLongCSPInResponseHeadersPolicy for exactly that case and docs/05 already specifies a CSP that would land there. RemoveHeadersConfig is a recorded gap: its cap is real but unpublished, and inventing a number would be worse. Both comments are now 76 and 74 characters. `--function-arn` is validated before any AWS call, and an unrecognised `--flag` is a usage error — the `=` form was invisible to the parser and to the presence check, for a clean exit 0 with no router attached. A skipped section now exits 3, because docs/09 uses exit 0 as its own success stamp and a partial run read as a complete one. Confirmed by measurement, as asked: the next run REUSES the orphan by name, matches every reconciled field, and stages it — create line gone, 4 changes down to 3, no duplicate and no collision. Reviewed twice. Round 2 found that the §7 record broke the table it lives in, and that two comments asserted behaviour the code did not have. 17 findings across both rounds, all fixed. Nothing was applied to the distribution and nothing was deployed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
This commit is contained in:
co-authored by
Claude Opus 5
parent
bbe535d158
commit
a07193d561
@@ -725,7 +725,7 @@ the audience it targets. Revisit at month 12–18. `[verified 2026-08-25 — dec
|
||||
| AWS account | `327082975128` `[verified 2026-08-26 — inventory]` |
|
||||
| Region | **`ca-central-1`** throughout — hosting, Lambda, DynamoDB `[verified 2026-08-26]` |
|
||||
| S3 bucket | **`adr-smlcompany-site`** — versioning **Enabled**, so rollback works `[verified 2026-08-26]` |
|
||||
| CloudFront | **`E1OK7G98KNKUTA`**, alias `adr.smlcompany.ca`, origin `adr-smlcompany-site.s3.ca-central-1.amazonaws.com` with OAC **`E13GAFUL6UQP6R`**, Deployed `[verified 2026-08-26; config re-read 2026-09-01]`. Default behaviour: `Managed-CachingOptimized`, `Managed-SecurityHeadersPolicy`, methods HEAD/GET, `redirect-to-https`, default root object `index.html`. 🟢 **ALL THREE ARE NOW PRESENT — `docs/09` Parts 1–3 ran at cutover.** `[verified 2026-09-04 — get-distribution-config]`: **1** function association (`adr-sml-router`, viewer-request), **1** custom error response (404 → `/404.html`, status 404), **1** cache behaviour (`/api/*` → `intake-api`), **2** origins. ⚠️ **THIS ROW READ "THREE THINGS THE SITE NEEDS ARE ABSENT … 22 of the 23 pages do not serve" UNTIL 2026-09-04**, stamped `[verified 2026-09-01]` — true when written, false from the moment Part 3 ran, and asserting that the live site does not serve. **Still to apply, and they are the only two outstanding:** the `*.pdf` behaviour with `adr-sml-pdf-noindex` (section 4) and the `/api/*` origin request policy `adr-sml-api-viewer-address` (section 5). Both need `configure.mjs --apply`, not a deploy. ⚠️ **AN `--apply` WAS ATTEMPTED ON 2026-09-04 AND FAILED ON ITS FIRST WRITE — so read "still to apply" as "attempted and rejected", not "never tried".** The state recorded above is unchanged and was **verified rather than assumed** after the failure: `/api/*` still on `b689b0a8-…`, **no** `*.pdf` behaviour, **zero** custom response-headers policies and **zero** custom origin request policies `[verified 2026-09-04 — get-distribution-config, list-response-headers-policies --type custom, list-origin-request-policies --type custom]` — **both** policy lists, because sections 4 and 5 each create one **before** `update-distribution` and a throw between them can orphan a policy. `update-distribution` is the script's last call, so a throw above it leaves the **distribution** untouched; it says nothing about the account, and this row cited only one of the two list calls for one round. Exact error, cause and fix: `docs/09` Part 3's incident record. The original text, because the trailing-slash reasoning is what makes the router load-bearing: *"the first is why 22 of the 23 pages do not serve — `astro.config.mjs` sets `trailingSlash: 'always'` with `build.format: 'directory'`, so CloudFront asks S3 for the key `about/`, which does not exist."* `infra/cloudfront/` holds the function and the config script |
|
||||
| CloudFront | **`E1OK7G98KNKUTA`**, alias `adr.smlcompany.ca`, origin `adr-smlcompany-site.s3.ca-central-1.amazonaws.com` with OAC **`E13GAFUL6UQP6R`**, Deployed `[verified 2026-08-26; config re-read 2026-09-01]`. Default behaviour: `Managed-CachingOptimized`, `Managed-SecurityHeadersPolicy`, methods HEAD/GET, `redirect-to-https`, default root object `index.html`. 🟢 **ALL THREE ARE NOW PRESENT — `docs/09` Parts 1–3 ran at cutover.** `[verified 2026-09-04 — get-distribution-config]`: **1** function association (`adr-sml-router`, viewer-request), **1** custom error response (404 → `/404.html`, status 404), **1** cache behaviour (`/api/*` → `intake-api`), **2** origins. ⚠️ **THIS ROW READ "THREE THINGS THE SITE NEEDS ARE ABSENT … 22 of the 23 pages do not serve" UNTIL 2026-09-04**, stamped `[verified 2026-09-01]` — true when written, false from the moment Part 3 ran, and asserting that the live site does not serve. **Still to apply, and they are the only two outstanding:** the `*.pdf` behaviour with `adr-sml-pdf-noindex` (section 4) and the `/api/*` origin request policy `adr-sml-api-viewer-address` (section 5). Both need `configure.mjs --apply`, not a deploy. 🛑 **TWO `--apply` ATTEMPTS WERE MADE ON 2026-09-04 AND BOTH FAILED BEFORE `update-distribution`. THE DISTRIBUTION IS UNCHANGED; THE ACCOUNT IS NOT.** Read "still to apply" as "attempted twice and rejected twice", never as "never tried". **Attempt 1** — section 4, `create-response-headers-policy`, `ParamValidation`: the cloned `SecurityHeadersConfig` carried `"ContentSecurityPolicy": {}`. Nothing was created. **Attempt 2** — section 4 **succeeded** and created the policy; section 5 then failed at `create-origin-request-policy` with `InvalidArgument`, *"The parameter Comment is too big"* — a **182-character** `Comment` against a **128** cap that the service model does not carry, so no client-side check saw it. 🟡 **THE ACCOUNT THEREFORE HOLDS ONE ORPHANED POLICY, AND IT IS EXPECTED, HARMLESS AND MUST NOT BE DELETED BY HAND:** `adr-sml-pdf-noindex` = **`51c4e79b-d9c6-4c6f-907c-dbb0e73dd374`**, referenced by no behaviour `[verified 2026-09-04]`. ⚠️ **THE SELF-HEALING CLAIM IS NOW MEASURED RATHER THAN ASSERTED** — this row and `docs/09` Part 3 predicted this exact outcome before it happened, and the next dry run **finds it by name, matches it on every reconciled field, and stages it for attachment**: `· response-headers policy adr-sml-pdf-noindex exists and matches the default behaviour`, with the real id in the change line and the create line **gone** — 4 changes down to 3 `[measured 2026-09-04, dry run, exit 0, nothing written]`. **No duplicate, no name collision, no error.** ⚠️ **`Comment` IS NOT A RECONCILED FIELD**, so `51c4e79b` keeps the original 118-character text while `configure.mjs` now carries a shorter one. Deliberate: adding `Comment` to the drift check would throw on this very policy and block the run that attaches it. Cosmetic, and it resolves only if the policy is ever recreated. State after both attempts, **verified rather than assumed**: `/api/*` still on `b689b0a8-…`, **no** `*.pdf` behaviour, **one** custom response-headers policy (the orphan above), **zero** custom origin request policies `[verified 2026-09-04 — get-distribution-config, list-response-headers-policies --type custom, list-origin-request-policies --type custom]` — **both** policy lists, because sections 4 and 5 each create one **before** `update-distribution`. `update-distribution` is the script's last call, so a throw above it leaves the **distribution** untouched; it says nothing about the account, and this row cited only one of the two list calls for one round. Exact error, cause and fix: `docs/09` Part 3's incident record. The original text, because the trailing-slash reasoning is what makes the router load-bearing: *"the first is why 22 of the 23 pages do not serve — `astro.config.mjs` sets `trailingSlash: 'always'` with `build.format: 'directory'`, so CloudFront asks S3 for the key `about/`, which does not exist."* `infra/cloudfront/` holds the function and the config script |
|
||||
| ACM certificate | `arn:aws:acm:us-east-1:327082975128:certificate/2b6d5bdf-6790-430c-9b82-c00ab66e6d87` — ISSUED `[verified 2026-08-26]` |
|
||||
| Intake API | `adr-intake-api`, HTTP API `4tl0m5igkj`, endpoint `https://4tl0m5igkj.execute-api.ca-central-1.amazonaws.com`. 🟢 **LIVE. ONE ROUTE, `POST /api/intake`** → integration `0ftgjgv` (`AWS_PROXY`, payload format **2.0**, which is the format `handler.mjs` reads). Stage `$default`, auto-deploy on, no access log. **THROTTLED per route: `POST /api/intake` → rate 1.0 req/s, burst 5, detailed metrics on** — the aggregate throttle `docs/05` §Validation specifies, `docs/09` Part 6.3, and it is a ROUTE setting rather than the stage default `[verified 2026-09-04 — get-routes, get-stage]`. `DisableExecuteApiEndpoint` is **false** and must stay false: the CloudFront origin **is** that hostname. ⚠️ **THIS ROW READ "One route, `POST /submissions`" AND "The form's route (`POST /api/intake`) does not exist yet" UNTIL 2026-09-04, TWO DAYS AFTER CUTOVER** — the old route was retired by `docs/09` Part 6.4 and the row was never re-read. It also said **no throttling**, which was true of the stage default and false of the route, and a query projecting `DefaultRouteSettings` alone reproduces that error exactly: **read `RouteSettings` too before concluding a throttle is absent** |
|
||||
| Intake Lambda | `adr-intake-handler`, `nodejs24.x`, **arm64**, role `adr-intake-lambda-role`. 🟢 **LIVE — IT IS `backend/intake/handler.mjs`, NOT THE HAND-BUILT FUNCTION.** Handler **`handler.handler`**, timeout **15 s**, memory **512 MB**, **six environment variables** (`INTAKE_TABLE`, `MAIL_FROM`, `NOTIFY_TO`, `NO_RETAINER_NOTICE`, `RESPONSE_TIME`, `SITE_ORIGIN`), no DLQ, code **3,314,989 bytes**, `CodeSha256` **`oMVpQYFxvjGCA6Wbz6UQGnQlS2QgVndT4BdoiCqsP1o=`**, last modified **2026-09-04T15:53:36Z** `[verified 2026-09-04 — get-function-configuration]`. ⚠️ **THAT CODE SIZE MEANS THE BUNDLED VARIANT, `docs/09` §5.5 — the runtime does not supply the SDK v3 clients.** The deployed artefact was downloaded via `get-function` `Code.Location` and read: it holds `handler.mjs`, `fields.mjs`, **`spam-score.mjs`**, `node_modules/` and `package.json`, and **all three source files are byte-identical to commit `3c3ba5d`** — sha1 `12cb09a8…`, `d0c0d15f…` and `5b4f9210…` `[verified 2026-09-04, after the 15:53Z redeploy]`. The archive holds **4,277 zip entries: 3,575 files and 702 directories**, 4,273 of the entries under `node_modules/`. ⚠️ **THIS ROW CALLED 4,277 A FILE COUNT** — that is `unzip -l`'s own footer wording, which prints `4277 files`, and 702 of them are directories. An operator re-deriving it with the obvious instrument, `find -type f`, gets **3,575** and a 702-entry mismatch against this register with no way to tell that from a truncated bundle. **Say what a number is a number OF** (`adversarial-reviewer`; recounted from the zip's central directory). ⚠️ **EVERY FIGURE IN THE TWO SENTENCES ABOVE MOVED WITH THAT REDEPLOY**, and nothing in the repository would have said so — the zip is built from the working tree and uploaded, and no committed file records what is running. The previous staleness of this row lasted **two days**; this one was caught **within the hour**, and the difference was that someone happened to be reading the row. It is what an operator consults before touching the Lambda, so **re-read it from `get-function-configuration` after every Part 5 run** rather than at the next convenient session. ⚠️ **THIS SAID "byte-identical to `HEAD`" AND `HEAD` IS A MOVING TARGET**: the 2026-09-04 change set edits both files and adds a third, so the sentence would have become false at its own commit while reading as current. **A digest claim about a deployed artefact names the commit it matched, never a ref.** The working tree is NOT what is running until `docs/09` Part 5 runs again. 🛑 **THE PACKAGE FILE LIST FOLLOWS NO IMPORT — it is `ls *.mjs` minus the tests, in both §5.1 and §5.5** (hand-typed in both until 2026-09-04, with nothing checking they agreed). `spam-score.mjs` joined it 2026-09-04 and a zip missing a module fails at cold start. Resource policy: **one** statement, `apigw-post-api-intake`, `apigateway.amazonaws.com` conditioned on `…/4tl0m5igkj/*/POST/api/intake` `[verified 2026-09-04 — get-policy]`. Execution role: `dynamodb:PutItem` on the table (write-only — it cannot read it), `ses:SendEmail`/`SendRawEmail`, plus `AWSLambdaBasicExecutionRole`. ⚠️ **THIS ROW SAID `index.handler`, 10 s, 128 MB, NO ENVIRONMENT VARIABLES, 1,527 BYTES AND "nothing has been deployed (D11)" UNTIL 2026-09-04**, stamped `[verified 2026-09-01]` — true when written, false from the moment Part 5 ran, and it is the row an operator reads before touching production. Deployment commands: `docs/09` Part 5 |
|
||||
@@ -957,6 +957,137 @@ never being raised again.
|
||||
|
||||
# Change Log
|
||||
|
||||
## 2026-09-04 (av) — The second `--apply` got one policy further and failed on a 128-character cap that nothing local enforces; the orphan it left is reused by name, measured; the dry run gains the only pre-flight this script has; and §7's own record broke the table it lives in
|
||||
|
||||
**Pouya's ruling of 2026-09-04**, after the second attempt: *"Fix: shorten BOTH
|
||||
policy comments to well under 128 characters… Add a dry-run assertion enforcing
|
||||
every CloudFront Comment field ≤128 characters — and while there, enumerate any
|
||||
other length/format limit on the payloads we send… since the dry run is the only
|
||||
pre-flight we have. Confirm on the next run section 4 REUSES 51c4e79b by name
|
||||
rather than creating a duplicate or erroring on the name collision — measure it
|
||||
in the dry run output. Record both failed attempts in docs/09 Part 3 and §7."*
|
||||
|
||||
---
|
||||
|
||||
### 1. What happened, and what it left behind
|
||||
|
||||
Attempt 2 got past attempt 1's `ParamValidation`: section 4 **created**
|
||||
`adr-sml-pdf-noindex` = **`51c4e79b-d9c6-4c6f-907c-dbb0e73dd374`**. Section 5
|
||||
then failed on a **182-character** `Comment` against a **128** cap —
|
||||
`InvalidArgument`, *"The parameter Comment is too big"*. `update-distribution`
|
||||
never ran. **The distribution is unchanged; the account is not.**
|
||||
|
||||
This is the orphan case `docs/09` Part 3 and §7 described **before it occurred**,
|
||||
including the instruction not to delete it by hand.
|
||||
|
||||
### 2. The reuse is measured, not asserted
|
||||
|
||||
`[measured 2026-09-04, dry run, exit 0, nothing written]` — the next run finds
|
||||
the orphan **by name**, matches every reconciled field, and stages it. The create
|
||||
line is gone and the change count drops **4 → 3**, with the real id inline. No
|
||||
duplicate, no collision — and a collision would not be silent either: a duplicate
|
||||
name returns **`ResponseHeadersPolicyAlreadyExists`**, a different error from the
|
||||
`InvalidArgument` above, and the script never reaches it because it looks the
|
||||
policy up by name first.
|
||||
|
||||
### 3. Why nothing local caught a 182-character string — two measurements
|
||||
|
||||
1. **`botocore/validate.py` checks neither `max` nor `pattern`.** `range_check()`
|
||||
reads `shape.metadata['min']` and nothing else; the word `pattern` does not
|
||||
appear in the file. A **declared** constraint is not applied.
|
||||
2. **The 128 is not declared as a constraint anyway.** On both policy configs
|
||||
`Comment` is a bare `string`; the cap lives in the shape's **`documentation`
|
||||
prose**.
|
||||
|
||||
⚠️ **BOTH CORRECTED CLAIMS IN THIS CHANGE SET'S OWN FIRST DRAFT.** The two
|
||||
`FunctionARN` entries were marked `enforcedBy: 'client'` because the model
|
||||
declares `{max: 108, pattern: …}`. And the first draft searched the model for
|
||||
`min`/`max`/`pattern` **keys**, concluded it was silent on `Comment`, and sourced
|
||||
128 as `[assumed]`. The model states it — in a field the search never read.
|
||||
|
||||
### 4. What was built
|
||||
|
||||
- **Both comments shortened** — 118 → **76**, 182 → **74**, each ~52 characters
|
||||
clear of the cap.
|
||||
- **`PAYLOAD_LIMITS`** in `policy-shapes.mjs`: **13** entries (7 + 4 + 2), four
|
||||
rule kinds (`maxLength`, `pattern`, `maxCount`, `maxCombinedLength`), each
|
||||
carrying its source. Count and combined-length quotas read off the CloudFront
|
||||
quotas page; `Comment` off the model's own documentation.
|
||||
- ⚠️ **THE ENTRIES THAT MATTER MOST GUARD *CLONED* VALUES.** Round 1's sharpest
|
||||
finding: every original entry constrained a literal this file authors —
|
||||
reviewed when written — while the values section 4 **copies** from the default
|
||||
behaviour's policy had none. `CreateResponseHeadersPolicy` declares
|
||||
`TooLongCSPInResponseHeadersPolicy` for exactly that, and `docs/05` already
|
||||
specifies a CSP that would land there. The documented **1,783** cap is now
|
||||
covered.
|
||||
- ⚠️ **KNOWN GAP, RECORDED RATHER THAN GUESSED.** `RemoveHeadersConfig` is also
|
||||
cloned and its count cap is **not published**; the operation declares
|
||||
`TooManyRemoveHeadersInResponseHeadersPolicy`, so a cap exists. Inventing a
|
||||
number would be worse than the gap.
|
||||
- **`--function-arn` validated before any AWS call**, closing the gap flagged and
|
||||
deliberately not built in entry (au). **An unrecognised `--flag` is now a usage
|
||||
error too** — `flag()` reads only the space form, so `--function-arn=<arn>` was
|
||||
invisible to it *and* to the presence check, for a clean exit 0 with no router
|
||||
attached.
|
||||
- **`Name` is `[assumed]` and says so.** No AWS source states a policy name
|
||||
length; the documented `Name` rule is **uniqueness**.
|
||||
- **Exit `3` when anything was skipped.** `docs/09` uses `exit 0` as its own
|
||||
success stamp, so a partial run read as a complete one — and two skip paths are
|
||||
new in this change set.
|
||||
|
||||
### 5. Two repairs contained defects worse than what they fixed
|
||||
|
||||
**A live-form-breaking one, found by its own probe.** Skipping only the *create*
|
||||
left the dependent staging in place: an `--apply` would have set `/api/*`'s
|
||||
`OriginRequestPolicyId` to **null**, removing header forwarding so every
|
||||
submission fails while looking like the visitor's browser. Section 4 had the same
|
||||
shape. Both now skip the whole section.
|
||||
|
||||
**And §7's own record broke the table it lives in** — the rewritten CloudFront
|
||||
row carried five literal newlines, so GFM parsed it as one two-cell row plus five
|
||||
single-cell rows, rendering the orphan warning and the policy id **in the label
|
||||
column** while the CloudFront row's value cell ended mid-sentence. Round 2 caught
|
||||
it. Rejoined as one row using plain spacing, not `<br>`: `AGENTS.md` contains no
|
||||
HTML and this change set is not the place to introduce it.
|
||||
|
||||
### 6. Review — two rounds, and the cap held
|
||||
|
||||
`adversarial-reviewer` only; **`claims-auditor` did not run (D20)**, and this
|
||||
change set reaches no public copy. **Round 1: 7 findings. Round 2: 10**, one
|
||||
blocking, and **two of the ten were comments asserting things the code does not
|
||||
do** — a comment naming an "EMPTY-OBJECT throw" that had been converted to a skip
|
||||
a session earlier, and a comment promising the payload is checked-then-sent while
|
||||
section 4 rebuilt a second literal at the call. That is D19's prediction in its
|
||||
own terms. All 17 fixed, none declined. **No round 3.**
|
||||
|
||||
### 7. Verification
|
||||
|
||||
`policy-shapes.test.mjs` **53 cases**; **19 of 19 mutations killed, 0 survived**.
|
||||
Two mutations survived earlier rounds and both were real coverage holes — the
|
||||
response-headers caps and the cloned-CSP cap could each be raised tenfold without
|
||||
failing anything, because every over-cap case had been written against the other
|
||||
policy. Cases were added and both now die.
|
||||
|
||||
Fault-injected and measured, each exit status read: an over-long cloned `Comment`
|
||||
**skips its whole section** (exit 3) while the other section still produces its
|
||||
changes; a missing `handler.mjs` skips section 5 entirely instead of falling
|
||||
through to the create; a broken reads-probe is caught rather than reporting
|
||||
`covers all 0 headers`; `--function-arn=<arn>` and an empty `--function-arn` both
|
||||
exit 2; the drift throw still fires; a matching policy still reports
|
||||
`exists and matches`.
|
||||
|
||||
**Gates, exit status read for each:** `check` 0 · `build` 0 (23 pages) ·
|
||||
`check:claims` 0 · `check:intake` 0 · `og:proof` 0 · `lint` 0 ·
|
||||
`router.test` 0 (30/30) · `policy-shapes.test` 0 (53/53) · `spam-score.test` 0
|
||||
(39/39) · minifier grep 1 (clean) · live dry run **0**, 59 lines, 3 changes, 0
|
||||
skips, nothing written. **`lighthouse` not run** — nothing under `src/` or
|
||||
`dist/` changed.
|
||||
|
||||
**Nothing was applied to the distribution and nothing was deployed. The orphaned
|
||||
policy was not touched.**
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-04 (au) — The first `--apply` failed on its first write because a policy AWS handed back is not a policy AWS accepts; the fix was wrong twice before it was right; and two production dependencies moved with no diff anywhere to show it
|
||||
|
||||
**Pouya's ruling of 2026-09-04**, after `configure.mjs --apply` failed: *"Fix §4's
|
||||
|
||||
+96
-20
@@ -323,19 +323,26 @@ node infra/cloudfront/configure.mjs --dist "$DIST_ID" --api-domain "$API_DOMAIN"
|
||||
|
||||
⚠️ **THE BLOCK BELOW IS THE `+` CHANGE LINES AND THE TWO RESOLVED-POLICY LINES.
|
||||
IT IS NOT THE WHOLE OUTPUT, AND IT SAID "exactly" UNTIL 2026-09-04.** Against
|
||||
the **live** distribution the dry run is **60 lines**, and all 60 account for:
|
||||
2 resolved-policy lines, **10** `·` lines, **4** `+` lines, a **40-line JSON
|
||||
the **live** distribution the dry run is **59 lines**, and all 59 account for:
|
||||
2 resolved-policy lines, **10** `·` lines, **3** `+` lines, a **40-line JSON
|
||||
dump** of the `*.pdf` behaviour it would add, 2 blank lines, the
|
||||
`N change(s) to distribution …` header and the `DRY RUN — nothing was sent.`
|
||||
footer `[measured 2026-09-04, exit 0, nothing written]`.
|
||||
|
||||
⚠️ **THAT 10/4 SPLIT IS THE LIVE STATE, NOT THE STATE THE BLOCK BELOW
|
||||
DESCRIBES.** Each of the four things Parts 1–3 have already applied prints `·`
|
||||
when it is found and `+` when it is staged, so in the Part 0.3 state those four
|
||||
move across: **6** `·` and **8** `+`, same 60 lines. **The total is not the
|
||||
check — the `+` lines are.** An operator told the output is "exactly" 14 lines
|
||||
either stops on a wall of unexplained text or stops comparing, and the paragraph
|
||||
after the block tells them a different set means *"stop and re-read it"*.
|
||||
🛑 **READ THE EXIT STATUS, AND IT HAS THREE VALUES.** `0` — everything this
|
||||
script manages was applied or is already present. `2` — a usage error, before any
|
||||
AWS call. **`3` — sections that could run did, and something was SKIPPED: read
|
||||
the `⚠ … SKIPPED, not changed` block.** Anything else is a throw. `3` exists
|
||||
because a skip used to exit `0`, and this document uses `exit 0` as its own
|
||||
success stamp throughout — so a partial run read as a complete one. A skip is
|
||||
never nothing: it means the `*.pdf` behaviour has no `X-Robots-Tag`, or `/api/*`
|
||||
is not forwarding `CloudFront-Viewer-Address`, and the skip line says which.
|
||||
|
||||
⚠️ **THE SHAPE CHANGES WITH THE STATE, SO READ THE `+` LINES AND NOT THE
|
||||
TOTAL.** Each item Parts 1–3 have already applied prints `·` when it is found
|
||||
and `+` when it is staged, so in the Part 0.3 state four lines cross from one
|
||||
column to the other and the totals move with them. **The `+` lines are the
|
||||
check.**
|
||||
|
||||
**Expect** — this is the dry run, and on a distribution in the state Part 0.3
|
||||
records the change lines are:
|
||||
@@ -370,15 +377,19 @@ Fewer than eight changes means part of this is already done — read which lines
|
||||
are prefixed `·` — but READ THE WORDS, not the bullet: `configure.mjs` uses `·`
|
||||
for *already present* **and** for *would CREATE / would SET / would ADD*, so the
|
||||
prefix alone does not say whether a line is done or still pending. **On the live distribution as at
|
||||
2026-09-04 the dry run returns exactly four `+` lines — the two for section 4
|
||||
and the two for section 5** — `[measured 2026-09-04, dry run against `E1OK7G98KNKUTA`, ETag
|
||||
2026-09-04, after the second `--apply` attempt, the dry run returns exactly
|
||||
three `+` lines — **one** for section 4 (the behaviour; its policy already
|
||||
exists, see the incident below) and the two for section 5** — `[measured 2026-09-04, dry run against `E1OK7G98KNKUTA`, ETag
|
||||
`E2EUQ1WTGCTBG2`, exit 0, nothing written]`. More than eight, or a different set,
|
||||
means the distribution is not in the state 0.3 recorded: stop and re-read it.
|
||||
|
||||
🛑 **INCIDENT, 2026-09-04 — THE FIRST `--apply` FAILED ON ITS FIRST WRITE, AND
|
||||
NOTHING REACHED THE DISTRIBUTION.** Section 4's clone was sent to
|
||||
`create-response-headers-policy` verbatim and the AWS CLI rejected it
|
||||
**client-side**, before the call left the machine:
|
||||
🛑 **INCIDENT — TWO `--apply` ATTEMPTS FAILED ON 2026-09-04, FOR TWO DIFFERENT
|
||||
REASONS, AND THE SECOND ONE LEFT A POLICY BEHIND.** The distribution is
|
||||
unchanged after both. Read both before the next attempt.
|
||||
|
||||
**ATTEMPT 1 — nothing reached the distribution and nothing was created.**
|
||||
Section 4's clone was sent to `create-response-headers-policy` verbatim and the
|
||||
AWS CLI rejected it **client-side**, before the call left the machine:
|
||||
|
||||
```
|
||||
An error occurred (ParamValidation): Parameter validation failed:
|
||||
@@ -425,8 +436,12 @@ aws cloudfront list-origin-request-policies --type custom --output json \
|
||||
```
|
||||
|
||||
An orphan is harmless and self-healing — the next run finds it by name, matches
|
||||
it and attaches it — **so do not delete it by hand.** Both returned `0` after
|
||||
the 2026-09-04 failure, which is why nothing needed doing that time
|
||||
it and attaches it — **so do not delete it by hand.**
|
||||
|
||||
⚠️ **THE EXPECTED VALUES DIFFER BY WHICH FAILURE YOU ARE RECOVERING FROM.** After
|
||||
**attempt 1** both returned `0` and nothing needed doing. After **attempt 2**
|
||||
they return **`1` and `0`** — the response-headers policy is the orphan recorded
|
||||
below, and `1` is the correct reading, not a second problem
|
||||
`[verified 2026-09-04]`.
|
||||
|
||||
**Two changes came out of it.** The clone now **omits** any empty member at
|
||||
@@ -449,9 +464,70 @@ otherwise be reached:
|
||||
node infra/cloudfront/policy-shapes.test.mjs
|
||||
```
|
||||
|
||||
**Expect** `policy-shapes: 23 of 23 cases pass`, exit 0. Its first case is this
|
||||
incident verbatim — the live `SecurityHeadersConfig`, empty `ContentSecurityPolicy`
|
||||
and all `[verified 2026-09-04; 7 of 7 mutations killed]`.
|
||||
**Expect** `policy-shapes: 53 of 53 cases pass`, exit 0. Its first case is this
|
||||
incident verbatim — the live `SecurityHeadersConfig`, empty
|
||||
`ContentSecurityPolicy` and all. The same suite covers attempt 2's limit checks,
|
||||
so this is the only command in this Part that proves both.
|
||||
|
||||
---
|
||||
|
||||
**ATTEMPT 2 — section 4 SUCCEEDED, section 5 FAILED, and the run left an orphan.**
|
||||
With the clone fixed, `create-response-headers-policy` created
|
||||
**`adr-sml-pdf-noindex` = `51c4e79b-d9c6-4c6f-907c-dbb0e73dd374`**. Section 5
|
||||
then failed:
|
||||
|
||||
```
|
||||
An error occurred (InvalidArgument) when calling the CreateOriginRequestPolicy
|
||||
operation: The parameter Comment is too big
|
||||
```
|
||||
|
||||
Its `Comment` was **182 characters** against a **128** cap. `update-distribution`
|
||||
never ran, so the distribution is untouched — **but the account now holds a
|
||||
response-headers policy that no behaviour references.**
|
||||
|
||||
🛑 **DO NOT DELETE THAT POLICY BY HAND.** This is the orphan case Part 3 and §7
|
||||
predicted before it happened, and the recovery is **measured, not asserted**
|
||||
`[measured 2026-09-04, dry run, exit 0, nothing written]`: the next run finds it
|
||||
**by name**, matches it on every reconciled field, and stages it for attachment —
|
||||
|
||||
```
|
||||
· response-headers policy adr-sml-pdf-noindex exists and matches the default behaviour
|
||||
+ CacheBehaviors += *.pdf -> …, adr-sml-pdf-noindex (51c4e79b-d9c6-4c6f-907c-dbb0e73dd374)
|
||||
```
|
||||
|
||||
— with the **create line gone** and the change count down from 4 to 3. **No
|
||||
duplicate and no name collision.** (A collision would not be silent either: a
|
||||
duplicate name returns `ResponseHeadersPolicyAlreadyExists`, which is a
|
||||
different error from the `InvalidArgument` above. The script never reaches it,
|
||||
because it looks the policy up by name first.)
|
||||
|
||||
⚠️ **`Comment` IS NOT RECONCILED**, so `51c4e79b` keeps its original
|
||||
118-character text while the script now carries a shorter one. Deliberate:
|
||||
adding `Comment` to the drift check would throw on this very policy and block
|
||||
the run that attaches it.
|
||||
|
||||
**WHY NOTHING CAUGHT IT LOCALLY, AND THIS IS THE GENERAL LESSON.** Measured
|
||||
2026-09-04: **`botocore/validate.py` checks neither `max` nor `pattern`** — its
|
||||
`range_check()` reads only `min`, and the word `pattern` does not appear in the
|
||||
file. And the 128 cap is not modelled as a constraint at all: on both policy
|
||||
configs `Comment` is a bare `string`, and the number lives in the shape's
|
||||
**`documentation` prose**. So **no client-side validation of any kind stood
|
||||
between that 182-character string and the CloudFront API**, which is exactly why
|
||||
the pre-flight below had to be built rather than relied upon.
|
||||
|
||||
**BOTH POLICY COMMENTS ARE NOW UNDER 80 CHARACTERS**, and the dry run enforces
|
||||
the limits it knows about — `infra/cloudfront/policy-shapes.mjs`,
|
||||
`PAYLOAD_LIMITS`, one entry per limit with the source it came from. **Every
|
||||
entry with a cited AWS source is enforced by the service and by nothing local**
|
||||
— see below. Two entries are stamped `[assumed]` and are not: no AWS source
|
||||
states a policy **name** length, and those two constrain nothing this script
|
||||
sends (our names are 19 and 26 characters). A breach **skips its section** rather than throwing, and — the part that
|
||||
matters — **skips the whole section**, so a policy that is not created is never
|
||||
staged for attachment. The suite named earlier in this Part
|
||||
(`node infra/cloudfront/policy-shapes.test.mjs`) covers both the 182-character
|
||||
`Comment` that failed here and the 118-character one that did not.
|
||||
|
||||
---
|
||||
|
||||
**One of the `·` lines carries a number worth reading**, and it is not a
|
||||
warning:
|
||||
|
||||
+195
-40
@@ -48,6 +48,7 @@ import {
|
||||
withoutEmptyMembers,
|
||||
isEmptyObject,
|
||||
emptyObjectPaths,
|
||||
limitViolations,
|
||||
} from './policy-shapes.mjs';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
@@ -57,6 +58,30 @@ const flag = (name) => {
|
||||
};
|
||||
const APPLY = args.includes('--apply');
|
||||
|
||||
/* ⚠️ AN UNRECOGNISED `--flag` IS A USAGE ERROR. `flag()` reads only the
|
||||
`--name <value>` form, so `--function-arn=<arn>` is invisible to it and to
|
||||
the presence check below: the run prints `no --function-arn given`, exits 0,
|
||||
and attaches no router. `--dist=` and `--api-domain=` already failed safe on
|
||||
the required-argument guard; `--function-arn=` was the one that degraded
|
||||
quietly. This closes the `=` form and typos together. */
|
||||
const KNOWN_FLAGS = new Set([
|
||||
'--dist',
|
||||
'--api-domain',
|
||||
'--function-arn',
|
||||
'--apply',
|
||||
]);
|
||||
const unknownFlags = args.filter(
|
||||
(a) => a.startsWith('--') && !KNOWN_FLAGS.has(a),
|
||||
);
|
||||
if (unknownFlags.length) {
|
||||
console.error(
|
||||
`unrecognised argument(s): ${unknownFlags.join(', ')}\n` +
|
||||
`Known flags: ${[...KNOWN_FLAGS].join(', ')}. A value is a separate ` +
|
||||
`argument — write \`--function-arn <arn>\`, not \`--function-arn=<arn>\`.`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const DIST = flag('dist');
|
||||
const API_DOMAIN = flag('api-domain');
|
||||
const FUNCTION_ARN = flag('function-arn');
|
||||
@@ -76,6 +101,35 @@ if (/^https?:/.test(API_DOMAIN) || API_DOMAIN.includes('/')) {
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
/* ⚠️ BEFORE ANY AWS CALL, because by the time the distribution payload exists
|
||||
sections 4 and 5 may already have created policies. `--api-domain` was
|
||||
guarded here and `--function-arn` was not, and the value that motivates this
|
||||
is not hypothetical: see `policy-shapes.mjs`, PAYLOAD_LIMITS. */
|
||||
/* ⚠️ PRESENCE, NOT TRUTHINESS. `docs/09` Part 2 says `$ROUTER_ARN` "is empty if
|
||||
`publish-function` failed", so `''` is the documented failure of the step
|
||||
that produces this argument — and falsy, so a truthiness test skips it here
|
||||
and again in section 1, for a clean exit 0 that attaches no router. */
|
||||
if (args.includes('--function-arn')) {
|
||||
const bad = FUNCTION_ARN
|
||||
? limitViolations('function-association', { FunctionARN: FUNCTION_ARN })
|
||||
: [
|
||||
{
|
||||
message:
|
||||
'--function-arn was given with an empty value. docs/09 Part 2: $ROUTER_ARN is empty if publish-function failed',
|
||||
},
|
||||
];
|
||||
if (bad.length) {
|
||||
console.error(
|
||||
`--function-arn is not a CloudFront function ARN:\n` +
|
||||
bad.map((b) => ` - ${b.message}`).join('\n') +
|
||||
`\n got: ${JSON.stringify(FUNCTION_ARN)}\n` +
|
||||
`Derive it with: aws cloudfront describe-function --name <name> --stage LIVE ` +
|
||||
`--query 'FunctionSummary.FunctionMetadata.FunctionARN' --output text\n` +
|
||||
`NOT with list-functions, which returns one row per stage and joins them.`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
/* stderr is NEVER suppressed and the exit status is always read — the AWS CLI
|
||||
reports an expired session, a missing permission and a typo'd id all on
|
||||
@@ -324,6 +378,14 @@ if (catchAll !== -1 && catchAll < apiIndex) {
|
||||
2026-09-03: all five security headers arrive on the live PDF today. */
|
||||
const PDF_PATTERN = '*.pdf';
|
||||
const PDF_POLICY_NAME = 'adr-sml-pdf-noindex';
|
||||
/* ⚠️ 128 CHARACTERS, SERVER-SIDE — nothing local checks it. Keep it short and
|
||||
put the explanation in section 4.
|
||||
|
||||
⚠️ `Comment` IS DELIBERATELY NOT IN THE DRIFT CHECK. The live policy carries
|
||||
the original, longer text (§7); reconciling it would throw on that policy and
|
||||
block the run that attaches it. */
|
||||
const PDF_POLICY_COMMENT =
|
||||
'X-Robots-Tag: noindex on *.pdf, cloned headers. See configure.mjs section 4.';
|
||||
const XRT = { Header: 'X-Robots-Tag', Value: 'noindex', Override: true };
|
||||
|
||||
const defaultRhpId = cfg.DefaultCacheBehavior.ResponseHeadersPolicyId;
|
||||
@@ -398,12 +460,17 @@ if (!defaultRhpId) {
|
||||
/* ⚠️ SKIP, NOT THROW — same rule as the missing-id case above, and it was
|
||||
inconsistent for one round. A policy carrying only `CorsConfig` is legal;
|
||||
an ABSENT source is section 4's problem alone and must not stop sections
|
||||
1-3 from re-applying `router.js`. TWO THROWS BELOW ARE DELIBERATE
|
||||
EXCEPTIONS, and they are exceptions for the same reason: DRIFT is a
|
||||
divergence rather than an absence (`docs/09` Part 3 argues for it), and the
|
||||
EMPTY-OBJECT throw means this script generated an invalid config — a bug in
|
||||
the script, not a state of the world. Neither is something a later section
|
||||
should be allowed to paper over. */
|
||||
1-3 from re-applying `router.js`.
|
||||
|
||||
⚠️ TWO CONDITIONS BELOW STILL ABORT THE WHOLE RUN, AND THEY ARE THE ONLY
|
||||
TWO: the DRIFT throw, and the throw on a `*.pdf` behaviour this script
|
||||
cannot account for. Both are divergences rather than absences — someone
|
||||
else has configured this distribution — and `docs/09` Part 3 argues for
|
||||
stopping on them. Everything else section 4 can hit, including an empty
|
||||
object in the generated config and a breached CloudFront limit, is a SKIP.
|
||||
(This comment said "the EMPTY-OBJECT throw" after that throw had been
|
||||
converted to a skip. Which conditions abort is this file's most
|
||||
safety-critical property; count them in the code, not here.) */
|
||||
if (!srcCfg?.SecurityHeadersConfig) {
|
||||
skipped.push(
|
||||
`${PDF_PATTERN} / ${PDF_POLICY_NAME} — response-headers policy ${defaultRhpId} has no SecurityHeadersConfig to clone`,
|
||||
@@ -457,15 +524,40 @@ if (!defaultRhpId) {
|
||||
section 4 never blocks sections 1-3 from re-applying `router.js`, without
|
||||
which 22 of 23 pages return S3's AccessDenied. A skip is already loud: it
|
||||
prints under its own heading and the NOTHING TO CHANGE guard names it. */
|
||||
/* Built once and checked, then sent — not rebuilt at the call. That is
|
||||
what makes the dry run a pre-flight. Checked only when a create would
|
||||
happen; a breach on a payload nobody sends is a false alarm. */
|
||||
const pdfPolicyConfig = {
|
||||
Name: PDF_POLICY_NAME,
|
||||
Comment: PDF_POLICY_COMMENT,
|
||||
...wanted,
|
||||
};
|
||||
const pdfBreaches = existingPdfPolicy
|
||||
? []
|
||||
: limitViolations('response-headers-policy', pdfPolicyConfig);
|
||||
const empties = emptyObjectPaths(wanted);
|
||||
if (empties.length) {
|
||||
skipped.push(
|
||||
`${PDF_PATTERN} / ${PDF_POLICY_NAME} — the generated policy config still ` +
|
||||
`contains ${empties.length} empty object(s) (${empties.join(', ')}), and ` +
|
||||
`AWS rejects those on ParamValidation before the call leaves the machine. ` +
|
||||
`withoutEmptyMembers should have removed them, so either it is broken or ` +
|
||||
`this script built one itself — see policy-shapes.mjs and its test.`,
|
||||
);
|
||||
/* ⚠️ ONE GUARD FOR THE WHOLE SECTION, NOT A BRANCH ROUND THE CREATE. No
|
||||
policy means `pdfPolicyId` stays null, and the behaviour below would then
|
||||
be staged carrying the placeholder string as its
|
||||
ResponseHeadersPolicyId. */
|
||||
const sectionFourBlockers = [
|
||||
...(empties.length
|
||||
? [
|
||||
`the generated policy config still contains ${empties.length} empty object(s) ` +
|
||||
`(${empties.join(', ')}), which AWS rejects on ParamValidation before the call ` +
|
||||
`leaves the machine. withoutEmptyMembers should have removed them, so either it ` +
|
||||
`is broken or this script built one itself — see policy-shapes.mjs and its test`,
|
||||
]
|
||||
: []),
|
||||
...pdfBreaches.map(
|
||||
(b) =>
|
||||
`the policy config would breach a CloudFront limit: ${b.message}`,
|
||||
),
|
||||
];
|
||||
if (sectionFourBlockers.length) {
|
||||
for (const blocker of sectionFourBlockers) {
|
||||
skipped.push(`${PDF_PATTERN} / ${PDF_POLICY_NAME} — ${blocker}`);
|
||||
}
|
||||
} else {
|
||||
if (existingPdfPolicy) {
|
||||
const have = existingPdfPolicy.ResponseHeadersPolicyConfig;
|
||||
@@ -504,7 +596,7 @@ if (!defaultRhpId) {
|
||||
k === 'CustomHeadersConfig'
|
||||
? have.CustomHeadersConfig?.Items
|
||||
: have[k],
|
||||
)}\n default : ${norm(
|
||||
)}\n wanted : ${norm(
|
||||
k === 'CustomHeadersConfig'
|
||||
? wanted.CustomHeadersConfig.Items
|
||||
: wanted[k],
|
||||
@@ -525,7 +617,8 @@ if (!defaultRhpId) {
|
||||
);
|
||||
} else if (!APPLY) {
|
||||
console.log(
|
||||
`· would CREATE response-headers policy ${PDF_POLICY_NAME}`,
|
||||
`· would CREATE response-headers policy ${PDF_POLICY_NAME}` +
|
||||
` (payload within every limit in PAYLOAD_LIMITS)`,
|
||||
);
|
||||
changes.push(
|
||||
`create response-headers policy ${PDF_POLICY_NAME} (SecurityHeadersConfig cloned from ${defaultRhpId} + X-Robots-Tag: noindex)`,
|
||||
@@ -535,12 +628,10 @@ if (!defaultRhpId) {
|
||||
'cloudfront',
|
||||
'create-response-headers-policy',
|
||||
'--response-headers-policy-config',
|
||||
JSON.stringify({
|
||||
Name: PDF_POLICY_NAME,
|
||||
Comment:
|
||||
'Cloned from the default behaviour, plus X-Robots-Tag: noindex for *.pdf. See infra/cloudfront/configure.mjs section 4.',
|
||||
...wanted,
|
||||
}),
|
||||
/* The object `limitViolations` inspected, not a second literal built
|
||||
here. Section 5 already did this; two literals that agree today are
|
||||
how a pre-flight stops covering the payload. */
|
||||
JSON.stringify(pdfPolicyConfig),
|
||||
'--output',
|
||||
'json',
|
||||
]);
|
||||
@@ -634,7 +725,10 @@ if (!defaultRhpId) {
|
||||
? ` (${pdfPolicyId})`
|
||||
: ' (policy id created in the same --apply pass)'),
|
||||
);
|
||||
if (!APPLY && !pdfPolicyId) {
|
||||
/* ⚠️ `!APPLY` ALONE, never gated on the policy id: knowing the id is a
|
||||
reason to show the payload, not to hide it. This is the only live
|
||||
change section 4 makes. */
|
||||
if (!APPLY) {
|
||||
console.log(
|
||||
`· would ADD cache behaviour ${PDF_PATTERN}:\n` +
|
||||
JSON.stringify(behaviour, null, 2)
|
||||
@@ -729,6 +823,13 @@ if (!defaultRhpId) {
|
||||
procedure for that. Forwarding a header is infrastructure. Storing it is a
|
||||
privacy-policy edit. */
|
||||
const ORP_NAME = 'adr-sml-api-viewer-address';
|
||||
/* ⚠️ THIS WAS 182 CHARACTERS AND IT FAILED THE SECOND `--apply`, 2026-09-04:
|
||||
InvalidArgument, "The parameter Comment is too big". The cap is 128 and it is
|
||||
server-side — the model types `Comment` as a bare `string`, so nothing local
|
||||
saw it. Section 4 had already created its policy by then, so the run left an
|
||||
orphan. `docs/09` Part 3 carries both attempts. */
|
||||
const ORP_COMMENT =
|
||||
'Forwards CloudFront-Viewer-Address on /api/*. See configure.mjs section 5.';
|
||||
/* Sorted, because the drift check below compares this list to what CloudFront
|
||||
returns and an ordering difference would read as a drift. */
|
||||
const ORP_HEADERS = [
|
||||
@@ -801,10 +902,25 @@ if (!apiBehaviour) {
|
||||
);
|
||||
} else {
|
||||
const reads = handlerHeaderReads();
|
||||
if (reads === null) {
|
||||
skipped.push(
|
||||
`${PATH_PATTERN} / ${ORP_NAME} — backend/intake/handler.mjs is not in this checkout, so the whitelist could not be checked against the handler's own reads`,
|
||||
);
|
||||
/* ⚠️ THE PROBE CAN BREAK, AND A BROKEN PROBE READS AS A CLEAN PASS. `reads`
|
||||
is `null` when the file is absent and `[]` when the regex stops matching —
|
||||
rename `headerOf`, switch to double quotes, pass the name as a constant,
|
||||
and `missing` is empty, nothing throws, and the run prints
|
||||
`· whitelist covers all 0 headers the handler reads ()` before replacing the
|
||||
policy on the intake form's path. CLAUDE.md: re-check "uniformly GOOD" too.
|
||||
The handler has four reads today.
|
||||
|
||||
⚠️ EITHER CASE SKIPS THE WHOLE SECTION — recording a skip and continuing
|
||||
would replace the policy on the live intake path with a whitelist nothing
|
||||
verified, under a heading saying nothing changed. */
|
||||
const readsBlocker =
|
||||
reads === null
|
||||
? "backend/intake/handler.mjs is not in this checkout, so the whitelist could not be checked against the handler's own reads"
|
||||
: reads.length === 0
|
||||
? "the handler-reads probe matched nothing. It greps for headerOf(event, '<name>') in backend/intake/handler.mjs, which has four reads today, so zero means the probe is broken rather than that the handler reads nothing"
|
||||
: null;
|
||||
if (readsBlocker) {
|
||||
skipped.push(`${PATH_PATTERN} / ${ORP_NAME} — ${readsBlocker}`);
|
||||
} else {
|
||||
const lower = ORP_HEADERS.map((h) => h.toLowerCase());
|
||||
const missing = reads.filter((h) => !lower.includes(h));
|
||||
@@ -823,7 +939,6 @@ if (!apiBehaviour) {
|
||||
console.log(
|
||||
`· whitelist covers all ${reads.length} headers the handler reads (${reads.join(', ')})`,
|
||||
);
|
||||
}
|
||||
|
||||
const existingOrp = findApiOriginRequestPolicy();
|
||||
let orpId = existingOrp?.Id ?? null;
|
||||
@@ -837,6 +952,17 @@ if (!apiBehaviour) {
|
||||
QueryStringsConfig: { QueryStringBehavior: 'all' },
|
||||
};
|
||||
|
||||
/* Built once and checked, for the reason section 4 gives — and this is the
|
||||
payload that actually failed. */
|
||||
const orpPolicyConfig = {
|
||||
Name: ORP_NAME,
|
||||
Comment: ORP_COMMENT,
|
||||
...wantedOrp,
|
||||
};
|
||||
const orpBreaches = existingOrp
|
||||
? []
|
||||
: limitViolations('origin-request-policy', orpPolicyConfig);
|
||||
|
||||
if (existingOrp) {
|
||||
const have = existingOrp.OriginRequestPolicyConfig;
|
||||
const norm = (o) => JSON.stringify(o ?? null);
|
||||
@@ -844,7 +970,9 @@ if (!apiBehaviour) {
|
||||
HeadersConfig object: CloudFront echoes `Quantity` back and a list that
|
||||
differs only in order is the same forwarding rule. A drift report that
|
||||
fires on ordering is a drift report nobody reads twice. */
|
||||
const haveHeaders = [...(have.HeadersConfig?.Headers?.Items ?? [])].sort();
|
||||
const haveHeaders = [
|
||||
...(have.HeadersConfig?.Headers?.Items ?? []),
|
||||
].sort();
|
||||
const drift = [];
|
||||
if (have.HeadersConfig?.HeaderBehavior !== 'whitelist')
|
||||
drift.push([
|
||||
@@ -868,7 +996,9 @@ if (!apiBehaviour) {
|
||||
`${drift.length} field(s). ${PATH_PATTERN} is the intake form's path, ` +
|
||||
`so read which way before repairing:\n` +
|
||||
drift
|
||||
.map(([k, a, b]) => ` ${k}\n live : ${a}\n wanted : ${b}`)
|
||||
.map(
|
||||
([k, a, b]) => ` ${k}\n live : ${a}\n wanted : ${b}`,
|
||||
)
|
||||
.join('\n') +
|
||||
`\nReconcile with update-origin-request-policy (it needs the policy's ` +
|
||||
`own ETag), then re-run.` +
|
||||
@@ -880,8 +1010,16 @@ if (!apiBehaviour) {
|
||||
);
|
||||
}
|
||||
console.log(`· origin request policy ${ORP_NAME} exists and matches`);
|
||||
} else if (orpBreaches.length) {
|
||||
skipped.push(
|
||||
`${PATH_PATTERN} / ${ORP_NAME} — the policy config this script would send breaches ` +
|
||||
`${orpBreaches.length} CloudFront limit(s): ${orpBreaches.map((b) => b.message).join('; ')}`,
|
||||
);
|
||||
} else if (!APPLY) {
|
||||
console.log(`· would CREATE origin request policy ${ORP_NAME}`);
|
||||
console.log(
|
||||
`· would CREATE origin request policy ${ORP_NAME}` +
|
||||
` (payload within every limit in PAYLOAD_LIMITS)`,
|
||||
);
|
||||
changes.push(
|
||||
`create origin request policy ${ORP_NAME} (whitelist: ${ORP_HEADERS.join(', ')}; cookies all; query strings all)`,
|
||||
);
|
||||
@@ -890,12 +1028,7 @@ if (!apiBehaviour) {
|
||||
'cloudfront',
|
||||
'create-origin-request-policy',
|
||||
'--origin-request-policy-config',
|
||||
JSON.stringify({
|
||||
Name: ORP_NAME,
|
||||
Comment:
|
||||
'Forwards CloudFront-Viewer-Address plus the four headers the intake handler reads. Replaces Managed-AllViewerExceptHostHeader on /api/*. See infra/cloudfront/configure.mjs section 5.',
|
||||
...wantedOrp,
|
||||
}),
|
||||
JSON.stringify(orpPolicyConfig),
|
||||
'--output',
|
||||
'json',
|
||||
]);
|
||||
@@ -908,7 +1041,18 @@ if (!apiBehaviour) {
|
||||
changes.push(`created origin request policy ${ORP_NAME} (${orpId})`);
|
||||
}
|
||||
|
||||
if (orpId && apiBehaviour.OriginRequestPolicyId === orpId) {
|
||||
/* 🛑 NO POLICY, NO ATTACHMENT. If the create above was skipped, `orpId` is
|
||||
null — and the `--apply` branch below assigns it unconditionally, so this
|
||||
would set the intake form's own behaviour to a null OriginRequestPolicyId.
|
||||
That REMOVES header forwarding from `/api/*`: `Origin`, `Referer` and
|
||||
`Content-Type` stop reaching the handler and every submission fails while
|
||||
looking like the visitor's browser. The dry run staged the line too, which
|
||||
is how this was found. */
|
||||
if (orpBreaches.length || (APPLY && !orpId)) {
|
||||
console.log(
|
||||
`· NOT touching ${PATH_PATTERN}'s OriginRequestPolicyId — ${ORP_NAME} was not created`,
|
||||
);
|
||||
} else if (orpId && apiBehaviour.OriginRequestPolicyId === orpId) {
|
||||
console.log(`· ${PATH_PATTERN} already uses ${ORP_NAME}`);
|
||||
} else if (!APPLY) {
|
||||
console.log(
|
||||
@@ -950,6 +1094,7 @@ if (!apiBehaviour) {
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
/* Skips print under their own heading and are NOT counted as changes — see the
|
||||
@@ -966,13 +1111,22 @@ if (skipped.length) {
|
||||
console.log(' CloudFront-Viewer-Address — the skip above says which.');
|
||||
console.log('');
|
||||
}
|
||||
/* ⚠️ A SKIPPED SECTION MUST NOT EXIT 0. `docs/09` uses `exit 0` as its own
|
||||
success stamp throughout, so a partial run that returned 0 read as a complete
|
||||
one — and two of the skip paths here are recent (a breached limit, a broken
|
||||
handler probe), where the same conditions previously surfaced as a failed AWS
|
||||
call, which is loud. 3 means: sections that could run did, something was
|
||||
skipped, read the ⚠ block. */
|
||||
const EXIT_SKIPPED = 3;
|
||||
const exitCode = skipped.length ? EXIT_SKIPPED : 0;
|
||||
|
||||
if (changes.length === 0) {
|
||||
console.log(
|
||||
skipped.length
|
||||
? 'NOTHING TO CHANGE — but see the skips above; the distribution does NOT carry all five.'
|
||||
: 'NOTHING TO CHANGE — the distribution already carries all five.',
|
||||
);
|
||||
process.exit(0);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
console.log(
|
||||
`${changes.length} change(s) to distribution ${DIST} (ETag ${etag}):`,
|
||||
@@ -982,7 +1136,7 @@ console.log('');
|
||||
|
||||
if (!APPLY) {
|
||||
console.log('DRY RUN — nothing was sent. Re-run with --apply to write it.');
|
||||
process.exit(0);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
const res = aws([
|
||||
@@ -1003,3 +1157,4 @@ console.log(
|
||||
"runbook's verification block:\n" +
|
||||
` aws cloudfront wait distribution-deployed --id ${DIST}`,
|
||||
);
|
||||
process.exit(exitCode);
|
||||
|
||||
@@ -75,3 +75,244 @@ export function emptyObjectPaths(value, path = '') {
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ **NOTHING LOCAL ENFORCES ANY OF THESE, WHICH IS WHY THIS TABLE EXISTS.**
|
||||
* Measured 2026-09-04: `botocore/validate.py` checks **neither `max` nor
|
||||
* `pattern`** — `range_check()` reads only `min`, and the word `pattern` does
|
||||
* not appear in the file — and the caps that matter are not modelled as
|
||||
* constraints anyway. On both policy configs `Comment` is a bare `string`, and
|
||||
* the 128 lives in the shape's **`documentation` prose**. So a 182-character
|
||||
* `Comment` left the machine unremarked and came back `InvalidArgument`, after
|
||||
* section 4 had already created its policy. **Every limit here is enforced by
|
||||
* the service and by nothing else**, which makes the dry run the only
|
||||
* pre-flight there is. `docs/09` Part 3 carries both attempts.
|
||||
*
|
||||
* ⚠️ **THE ENTRIES THAT MATTER MOST GUARD *CLONED* VALUES, NOT LITERALS THIS
|
||||
* FILE AUTHORS.** A literal we write is reviewed when it is written; a value
|
||||
* copied out of the default behaviour's policy changes without anyone here
|
||||
* touching it, and `docs/05` already specifies a Content-Security-Policy that
|
||||
* would land there. `CreateResponseHeadersPolicy` declares a dedicated error
|
||||
* for exactly that — `TooLongCSPInResponseHeadersPolicy`.
|
||||
*
|
||||
* ⚠️ **KNOWN GAP, RECORDED RATHER THAN GUESSED: `RemoveHeadersConfig` is cloned
|
||||
* too and its count cap is not published.** The operation declares
|
||||
* `TooManyRemoveHeadersInResponseHeadersPolicy`, so a cap exists; the quotas
|
||||
* page states no number and inventing one would be worse than the gap. A breach
|
||||
* there surfaces as that error at the write, not as a pre-flight skip.
|
||||
*
|
||||
* ⚠️ **ABSENCE FROM A SOURCE IS NOT ABSENCE OF A LIMIT.** Entries marked
|
||||
* `[assumed]` have no AWS source at all; they are kept because they cost nothing
|
||||
* and constrain nothing this script sends.
|
||||
*/
|
||||
export const PAYLOAD_LIMITS = {
|
||||
'response-headers-policy': [
|
||||
{
|
||||
path: 'Name',
|
||||
rule: 'maxLength',
|
||||
limit: 128,
|
||||
source:
|
||||
'[assumed] — no AWS source states a policy name length; the documented Name rule is uniqueness. Pouya, 2026-09-04',
|
||||
},
|
||||
{
|
||||
path: 'Comment',
|
||||
rule: 'maxLength',
|
||||
limit: 128,
|
||||
source:
|
||||
'service model, ResponseHeadersPolicyConfig.Comment documentation: "The comment cannot be longer than 128 characters"',
|
||||
},
|
||||
{
|
||||
/* CLONED, not authored here — see the header. */
|
||||
path: 'SecurityHeadersConfig.ContentSecurityPolicy.ContentSecurityPolicy',
|
||||
rule: 'maxLength',
|
||||
limit: 1783,
|
||||
source:
|
||||
'CloudFront quotas, Quotas on headers: "Maximum length of the Content-Security-Policy header value | 1,783 characters"; error shape TooLongCSPInResponseHeadersPolicy',
|
||||
},
|
||||
{
|
||||
path: 'CustomHeadersConfig.Items[].Header',
|
||||
rule: 'maxLength',
|
||||
limit: 256,
|
||||
source:
|
||||
'CloudFront quotas, Quotas on headers: "Custom headers: maximum length of a header name | 256 characters"',
|
||||
},
|
||||
{
|
||||
path: 'CustomHeadersConfig.Items[].Value',
|
||||
rule: 'maxLength',
|
||||
limit: 1783,
|
||||
source:
|
||||
'CloudFront quotas, Quotas on headers: "Custom headers: maximum length of a header value | 1,783 characters"',
|
||||
},
|
||||
{
|
||||
path: 'CustomHeadersConfig.Items[]',
|
||||
rule: 'maxCount',
|
||||
limit: 10,
|
||||
source:
|
||||
'CloudFront quotas: "maximum number of custom headers that you can add to a response headers policy | 10" (adjustable); error shape TooManyCustomHeadersInResponseHeadersPolicy',
|
||||
},
|
||||
{
|
||||
paths: [
|
||||
'CustomHeadersConfig.Items[].Header',
|
||||
'CustomHeadersConfig.Items[].Value',
|
||||
],
|
||||
rule: 'maxCombinedLength',
|
||||
limit: 10240,
|
||||
source:
|
||||
'CloudFront quotas: "Custom headers: maximum length of all header values and names combined | 10,240 characters"',
|
||||
},
|
||||
],
|
||||
'origin-request-policy': [
|
||||
{
|
||||
path: 'Name',
|
||||
rule: 'maxLength',
|
||||
limit: 128,
|
||||
source: '[assumed] — see the response-headers-policy Name entry',
|
||||
},
|
||||
{
|
||||
path: 'Comment',
|
||||
rule: 'maxLength',
|
||||
limit: 128,
|
||||
source:
|
||||
'service model, OriginRequestPolicyConfig.Comment documentation: "The comment cannot be longer than 128 characters". This is the one that failed on 2026-09-04 at 182',
|
||||
},
|
||||
{
|
||||
path: 'HeadersConfig.Headers.Items[]',
|
||||
rule: 'maxCount',
|
||||
limit: 10,
|
||||
source:
|
||||
'CloudFront quotas: "Headers per origin request policy | 10" (adjustable); error shape TooManyHeadersInOriginRequestPolicy. We send 5',
|
||||
},
|
||||
{
|
||||
paths: ['HeadersConfig.Headers.Items[]'],
|
||||
rule: 'maxCombinedLength',
|
||||
limit: 1024,
|
||||
source:
|
||||
'CloudFront quotas: "Total combined length of all query string, header, and cookie names in an origin request policy | 1024". We contribute header names only',
|
||||
},
|
||||
],
|
||||
/* Checked as a flag before any AWS call, because by the time a distribution
|
||||
payload exists sections 4 and 5 may already have created policies.
|
||||
|
||||
⚠️ NOT DECORATION. `aws cloudfront list-functions --output text` returns the
|
||||
ARN twice, tab-joined, because the function exists in a DEVELOPMENT and a
|
||||
LIVE stage — 113 characters, and it fails the pattern too. Staging that
|
||||
replaces a working `router.js` association with a value CloudFront will not
|
||||
accept, and `router.js` keeps 22 of 23 pages off S3's AccessDenied.
|
||||
`docs/09` Part 2 derives it correctly with `describe-function --stage LIVE`. */
|
||||
'function-association': [
|
||||
{
|
||||
path: 'FunctionARN',
|
||||
rule: 'maxLength',
|
||||
limit: 108,
|
||||
source: "service model, shape FunctionARN: {'max': 108}",
|
||||
},
|
||||
{
|
||||
path: 'FunctionARN',
|
||||
rule: 'pattern',
|
||||
limit: 'arn:aws:cloudfront::[0-9]{12}:function\\/[a-zA-Z0-9-_]{1,64}',
|
||||
source: 'service model, shape FunctionARN: pattern',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a dotted path, where `[]` means "every element of this array". Always
|
||||
* returns `{path, value}` pairs with the index substituted, so a violation
|
||||
* names the element rather than the collection.
|
||||
*/
|
||||
function resolvePath(root, path) {
|
||||
let frontier = [{ path: '', value: root }];
|
||||
for (const segment of path.split('.')) {
|
||||
const next = [];
|
||||
const isArray = segment.endsWith('[]');
|
||||
const key = isArray ? segment.slice(0, -2) : segment;
|
||||
for (const { path: p, value } of frontier) {
|
||||
const child = value?.[key];
|
||||
const here = p ? `${p}.${key}` : key;
|
||||
if (child === undefined || child === null) continue;
|
||||
if (isArray) {
|
||||
if (!Array.isArray(child)) continue;
|
||||
child.forEach((v, i) => next.push({ path: `${here}[${i}]`, value: v }));
|
||||
} else {
|
||||
next.push({ path: here, value: child });
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
return frontier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every limit the given payload breaches. Empty means it is safe to send as far
|
||||
* as this table knows — which is a claim about the table, not about AWS.
|
||||
*/
|
||||
export function limitViolations(kind, payload) {
|
||||
const rules = PAYLOAD_LIMITS[kind];
|
||||
if (!rules) throw new Error(`no limit table for payload kind '${kind}'`);
|
||||
const out = [];
|
||||
const add = (v) => out.push(v);
|
||||
|
||||
for (const rule of rules) {
|
||||
/* `paths` (plural) is for the aggregate rules, where AWS caps a total
|
||||
across more than one field — header names AND values combined. */
|
||||
const paths = rule.paths ?? [rule.path];
|
||||
const label = paths.join(' + ');
|
||||
const resolved = paths.flatMap((one) => resolvePath(payload, one));
|
||||
|
||||
if (rule.rule === 'maxCount') {
|
||||
if (resolved.length > rule.limit) {
|
||||
add({
|
||||
path: label,
|
||||
rule: 'maxCount',
|
||||
actual: resolved.length,
|
||||
limit: rule.limit,
|
||||
message: `${label} has ${resolved.length} entries; the limit is ${rule.limit} (${rule.source})`,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rule.rule === 'maxCombinedLength') {
|
||||
const total = resolved.reduce(
|
||||
(n, { value }) => n + (typeof value === 'string' ? value.length : 0),
|
||||
0,
|
||||
);
|
||||
if (total > rule.limit) {
|
||||
add({
|
||||
path: label,
|
||||
rule: 'maxCombinedLength',
|
||||
actual: total,
|
||||
limit: rule.limit,
|
||||
message: `${label} totals ${total} characters; the limit is ${rule.limit} (${rule.source})`,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const { path, value } of resolved) {
|
||||
if (typeof value !== 'string') continue;
|
||||
if (rule.rule === 'maxLength' && value.length > rule.limit) {
|
||||
add({
|
||||
path,
|
||||
rule: 'maxLength',
|
||||
actual: value.length,
|
||||
limit: rule.limit,
|
||||
message: `${path} is ${value.length} characters; the limit is ${rule.limit} (${rule.source})`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
rule.rule === 'pattern' &&
|
||||
!new RegExp(`^(?:${rule.limit})$`).test(value)
|
||||
) {
|
||||
add({
|
||||
path,
|
||||
rule: 'pattern',
|
||||
actual: JSON.stringify(value),
|
||||
limit: rule.limit,
|
||||
message: `${path} does not match ${rule.limit} (${rule.source})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
withoutEmptyMembers,
|
||||
emptyObjectPaths,
|
||||
isEmptyObject,
|
||||
limitViolations,
|
||||
PAYLOAD_LIMITS,
|
||||
} from './policy-shapes.mjs';
|
||||
|
||||
let pass = 0;
|
||||
@@ -195,6 +197,325 @@ t(
|
||||
[],
|
||||
);
|
||||
|
||||
/* ---- PAYLOAD_LIMITS: the 2026-09-04 second failure -----------------------
|
||||
InvalidArgument, "The parameter Comment is too big", from
|
||||
create-origin-request-policy. The model types Comment as a bare `string`, so
|
||||
ParamValidation could not see it and the dry run was the only place it could
|
||||
have been caught. */
|
||||
const ORP = (comment) => ({
|
||||
Name: 'adr-sml-api-viewer-address',
|
||||
Comment: comment,
|
||||
HeadersConfig: {
|
||||
HeaderBehavior: 'whitelist',
|
||||
Headers: { Quantity: 1, Items: ['Origin'] },
|
||||
},
|
||||
});
|
||||
t(
|
||||
'the 182-character Comment that failed is reported',
|
||||
limitViolations('origin-request-policy', ORP('x'.repeat(182))).map((v) => [
|
||||
v.path,
|
||||
v.actual,
|
||||
v.limit,
|
||||
]),
|
||||
[['Comment', 182, 128]],
|
||||
);
|
||||
t(
|
||||
'the shipped Comment passes',
|
||||
limitViolations(
|
||||
'origin-request-policy',
|
||||
ORP(
|
||||
'Forwards CloudFront-Viewer-Address on /api/*. See configure.mjs section 5.',
|
||||
),
|
||||
),
|
||||
[],
|
||||
);
|
||||
t(
|
||||
'128 exactly is allowed — the cap is inclusive',
|
||||
limitViolations('origin-request-policy', ORP('x'.repeat(128))),
|
||||
[],
|
||||
);
|
||||
t(
|
||||
'129 is not',
|
||||
limitViolations('origin-request-policy', ORP('x'.repeat(129))).length,
|
||||
1,
|
||||
);
|
||||
t(
|
||||
'the 118-character Comment AWS accepted on 2026-09-04 passes',
|
||||
limitViolations('response-headers-policy', {
|
||||
Name: 'adr-sml-pdf-noindex',
|
||||
Comment:
|
||||
'Cloned from the default behaviour, plus X-Robots-Tag: noindex for *.pdf. See infra/cloudfront/configure.mjs section 4.',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
/* Section 4's payload needs its OWN over-cap cases: asserting only that the
|
||||
accepted 118 passes leaves the cap free to be wrong in the loose direction,
|
||||
which a mutation raising it to 1280 proved by surviving. */
|
||||
t(
|
||||
'a 129-character response-headers Comment is caught',
|
||||
limitViolations('response-headers-policy', {
|
||||
Name: 'adr-sml-pdf-noindex',
|
||||
Comment: 'x'.repeat(129),
|
||||
}).map((v) => [v.path, v.actual, v.limit]),
|
||||
[['Comment', 129, 128]],
|
||||
);
|
||||
t(
|
||||
'and 128 exactly is allowed',
|
||||
limitViolations('response-headers-policy', {
|
||||
Name: 'adr-sml-pdf-noindex',
|
||||
Comment: 'x'.repeat(128),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
t(
|
||||
'an over-long policy Name is caught on both policy kinds',
|
||||
[
|
||||
limitViolations('response-headers-policy', { Name: 'n'.repeat(129) })
|
||||
.length,
|
||||
limitViolations('origin-request-policy', { Name: 'n'.repeat(129) }).length,
|
||||
],
|
||||
[1, 1],
|
||||
);
|
||||
t(
|
||||
'and both shipped names pass',
|
||||
[
|
||||
limitViolations('response-headers-policy', { Name: 'adr-sml-pdf-noindex' })
|
||||
.length,
|
||||
limitViolations('origin-request-policy', {
|
||||
Name: 'adr-sml-api-viewer-address',
|
||||
}).length,
|
||||
],
|
||||
[0, 0],
|
||||
);
|
||||
|
||||
/* ---- the function ARN, the one limit the service model does give us ------ */
|
||||
const GOOD_ARN = 'arn:aws:cloudfront::327082975128:function/adr-sml-router';
|
||||
t(
|
||||
'a correctly derived function ARN passes',
|
||||
limitViolations('function-association', { FunctionARN: GOOD_ARN }),
|
||||
[],
|
||||
);
|
||||
t(
|
||||
'the tab-doubled ARN that `list-functions --output text` returns breaks both rules',
|
||||
limitViolations('function-association', {
|
||||
FunctionARN: `${GOOD_ARN}\t${GOOD_ARN}`,
|
||||
})
|
||||
.map((v) => v.rule)
|
||||
.sort(),
|
||||
['maxLength', 'pattern'],
|
||||
);
|
||||
t(
|
||||
'a Lambda@Edge ARN is not a CloudFront function ARN',
|
||||
limitViolations('function-association', {
|
||||
FunctionARN: 'arn:aws:lambda:us-east-1:327082975128:function:edge',
|
||||
}).some((v) => v.rule === 'pattern'),
|
||||
true,
|
||||
);
|
||||
|
||||
/* ---- the aggregate rules, both documented on the CloudFront quotas page -- */
|
||||
const HDRS = (items) => ({
|
||||
Name: 'adr-sml-api-viewer-address',
|
||||
Comment: 'c',
|
||||
HeadersConfig: {
|
||||
HeaderBehavior: 'whitelist',
|
||||
Headers: { Quantity: items.length, Items: items },
|
||||
},
|
||||
});
|
||||
const SHIPPED_HEADERS = [
|
||||
'CloudFront-Viewer-Address',
|
||||
'Content-Type',
|
||||
'Origin',
|
||||
'Referer',
|
||||
'User-Agent',
|
||||
];
|
||||
t(
|
||||
'the five headers we actually whitelist pass every rule',
|
||||
limitViolations('origin-request-policy', HDRS(SHIPPED_HEADERS)),
|
||||
[],
|
||||
);
|
||||
t(
|
||||
'an 11th whitelisted header breaches "Headers per origin request policy | 10"',
|
||||
limitViolations(
|
||||
'origin-request-policy',
|
||||
HDRS(Array.from({ length: 11 }, (_, i) => `X-H${i}`)),
|
||||
).map((v) => [v.rule, v.actual, v.limit]),
|
||||
[['maxCount', 11, 10]],
|
||||
);
|
||||
t(
|
||||
'ten is allowed',
|
||||
limitViolations(
|
||||
'origin-request-policy',
|
||||
HDRS(Array.from({ length: 10 }, (_, i) => `X-H${i}`)),
|
||||
),
|
||||
[],
|
||||
);
|
||||
t(
|
||||
'header names totalling over 1024 breach the combined-length quota',
|
||||
limitViolations(
|
||||
'origin-request-policy',
|
||||
HDRS(Array.from({ length: 9 }, () => 'X'.repeat(120))),
|
||||
)
|
||||
.map((v) => v.rule)
|
||||
.sort(),
|
||||
['maxCombinedLength'],
|
||||
);
|
||||
|
||||
t(
|
||||
'an 11th custom response header breaches its own count quota',
|
||||
limitViolations('response-headers-policy', {
|
||||
Name: 'n',
|
||||
CustomHeadersConfig: {
|
||||
Quantity: 11,
|
||||
Items: Array.from({ length: 11 }, (_, i) => ({
|
||||
Header: `X-${i}`,
|
||||
Value: 'v',
|
||||
})),
|
||||
},
|
||||
}).map((v) => v.rule),
|
||||
['maxCount'],
|
||||
);
|
||||
t(
|
||||
'the single X-Robots-Tag header we add passes',
|
||||
limitViolations('response-headers-policy', {
|
||||
Name: 'adr-sml-pdf-noindex',
|
||||
Comment:
|
||||
'X-Robots-Tag: noindex on *.pdf, cloned headers. See configure.mjs section 4.',
|
||||
CustomHeadersConfig: {
|
||||
Quantity: 1,
|
||||
Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }],
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
t(
|
||||
'names and values combined over 10,240 are caught',
|
||||
limitViolations('response-headers-policy', {
|
||||
Name: 'n',
|
||||
CustomHeadersConfig: {
|
||||
Quantity: 8,
|
||||
Items: Array.from({ length: 8 }, (_, i) => ({
|
||||
Header: `X-${i}`,
|
||||
Value: 'v'.repeat(1500),
|
||||
})),
|
||||
},
|
||||
}).some((v) => v.rule === 'maxCombinedLength'),
|
||||
true,
|
||||
);
|
||||
|
||||
/* ---- the walker -------------------------------------------------------- */
|
||||
t(
|
||||
'[] resolves every element and the violation names the index',
|
||||
limitViolations('response-headers-policy', {
|
||||
Name: 'n',
|
||||
CustomHeadersConfig: {
|
||||
Quantity: 2,
|
||||
Items: [
|
||||
{ Header: 'X-Robots-Tag', Value: 'noindex' },
|
||||
{ Header: 'X'.repeat(300), Value: 'v' },
|
||||
],
|
||||
},
|
||||
}).map((v) => v.path),
|
||||
['CustomHeadersConfig.Items[1].Header'],
|
||||
);
|
||||
t(
|
||||
'an absent field is not a violation',
|
||||
limitViolations('response-headers-policy', { Name: 'n' }),
|
||||
[],
|
||||
);
|
||||
t(
|
||||
'a non-string value is skipped rather than crashing',
|
||||
limitViolations('response-headers-policy', { Name: 'n', Comment: 12345 }),
|
||||
[],
|
||||
);
|
||||
t(
|
||||
'a null along the path is skipped',
|
||||
limitViolations('response-headers-policy', {
|
||||
Name: 'n',
|
||||
CustomHeadersConfig: null,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
t(
|
||||
'an unknown payload kind throws rather than passing silently',
|
||||
(() => {
|
||||
try {
|
||||
limitViolations('nope', {});
|
||||
return 'no throw';
|
||||
} catch (e) {
|
||||
return e.message.includes('nope');
|
||||
}
|
||||
})(),
|
||||
true,
|
||||
);
|
||||
t(
|
||||
'every limit entry carries a source',
|
||||
Object.values(PAYLOAD_LIMITS)
|
||||
.flat()
|
||||
.every((l) => typeof l.source === 'string' && l.source.length > 0),
|
||||
true,
|
||||
);
|
||||
t(
|
||||
'every entry addresses exactly one of path / paths',
|
||||
Object.values(PAYLOAD_LIMITS)
|
||||
.flat()
|
||||
.every((l) => (l.path === undefined) !== (l.paths === undefined)),
|
||||
true,
|
||||
);
|
||||
|
||||
/* ---- the CLONED values, which change without this file being touched.
|
||||
`docs/05` specifies a Content-Security-Policy that would land on the default
|
||||
behaviour's policy and be copied straight into ours; the API declares
|
||||
TooLongCSPInResponseHeadersPolicy for exactly that. */
|
||||
const withCsp = (csp) => ({
|
||||
Name: 'adr-sml-pdf-noindex',
|
||||
Comment: 'c',
|
||||
SecurityHeadersConfig: {
|
||||
ContentTypeOptions: { Override: true },
|
||||
ContentSecurityPolicy: { Override: false, ContentSecurityPolicy: csp },
|
||||
},
|
||||
});
|
||||
t(
|
||||
'a cloned CSP over 1,783 characters is caught before the create',
|
||||
limitViolations('response-headers-policy', withCsp('x'.repeat(1784))).map(
|
||||
(v) => [v.path, v.actual, v.limit],
|
||||
),
|
||||
[
|
||||
[
|
||||
'SecurityHeadersConfig.ContentSecurityPolicy.ContentSecurityPolicy',
|
||||
1784,
|
||||
1783,
|
||||
],
|
||||
],
|
||||
);
|
||||
t(
|
||||
'1,783 exactly is allowed',
|
||||
limitViolations('response-headers-policy', withCsp('x'.repeat(1783))),
|
||||
[],
|
||||
);
|
||||
t(
|
||||
'a realistic CSP passes',
|
||||
limitViolations(
|
||||
'response-headers-policy',
|
||||
withCsp("default-src 'self'; img-src 'self' data:; style-src 'self'"),
|
||||
),
|
||||
[],
|
||||
);
|
||||
t(
|
||||
'the live source policy, which defines no CSP at all, passes whole',
|
||||
limitViolations('response-headers-policy', {
|
||||
Name: 'adr-sml-pdf-noindex',
|
||||
Comment:
|
||||
'X-Robots-Tag: noindex on *.pdf, cloned headers. See configure.mjs section 4.',
|
||||
SecurityHeadersConfig: stripped,
|
||||
CustomHeadersConfig: {
|
||||
Quantity: 1,
|
||||
Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }],
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
if (failures.length) {
|
||||
console.error(
|
||||
`policy-shapes: ${failures.length} FAILED\n - ${failures.join('\n - ')}`,
|
||||
|
||||
Reference in New Issue
Block a user