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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user