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
@@ -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