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
526 lines
15 KiB
JavaScript
526 lines
15 KiB
JavaScript
/**
|
|
* Tests for `policy-shapes.mjs` — the two functions that answer the 2026-09-04
|
|
* `--apply` failure recorded in `docs/09` Part 3.
|
|
*
|
|
* The first case is that failure verbatim: the `SecurityHeadersConfig` the live
|
|
* `Managed-SecurityHeadersPolicy` returns, empty `ContentSecurityPolicy` and
|
|
* all, which is what `create-response-headers-policy` rejected.
|
|
*
|
|
* node infra/cloudfront/policy-shapes.test.mjs
|
|
*/
|
|
import {
|
|
withoutEmptyMembers,
|
|
emptyObjectPaths,
|
|
isEmptyObject,
|
|
limitViolations,
|
|
PAYLOAD_LIMITS,
|
|
} from './policy-shapes.mjs';
|
|
|
|
let pass = 0;
|
|
const failures = [];
|
|
const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
const t = (name, got, want) => {
|
|
if (eq(got, want)) pass += 1;
|
|
else
|
|
failures.push(
|
|
`${name}\n got ${JSON.stringify(got)}\n want ${JSON.stringify(want)}`,
|
|
);
|
|
};
|
|
|
|
/* The live source policy, copied from `get-response-headers-policy` on
|
|
67f7725c-6f97-4210-82d7-5512b31e9d03 [verified 2026-09-04]. */
|
|
const LIVE_SECURITY_HEADERS = {
|
|
XSSProtection: { Override: false, Protection: true, ModeBlock: true },
|
|
FrameOptions: { Override: false, FrameOption: 'SAMEORIGIN' },
|
|
ReferrerPolicy: {
|
|
Override: false,
|
|
ReferrerPolicy: 'strict-origin-when-cross-origin',
|
|
},
|
|
ContentSecurityPolicy: {},
|
|
ContentTypeOptions: { Override: true },
|
|
StrictTransportSecurity: {
|
|
Override: false,
|
|
AccessControlMaxAgeSec: 31536000,
|
|
},
|
|
};
|
|
|
|
/* ---- the incident itself ------------------------------------------------ */
|
|
const stripped = withoutEmptyMembers(LIVE_SECURITY_HEADERS);
|
|
t(
|
|
'the 2026-09-04 breach: ContentSecurityPolicy is dropped',
|
|
Object.keys(stripped).sort(),
|
|
[
|
|
'ContentTypeOptions',
|
|
'FrameOptions',
|
|
'ReferrerPolicy',
|
|
'StrictTransportSecurity',
|
|
'XSSProtection',
|
|
],
|
|
);
|
|
t(
|
|
'and five survive — the count docs/09 Part 3 tells the operator to read',
|
|
Object.keys(stripped).length,
|
|
5,
|
|
);
|
|
t(
|
|
'the surviving members are untouched',
|
|
stripped.StrictTransportSecurity,
|
|
LIVE_SECURITY_HEADERS.StrictTransportSecurity,
|
|
);
|
|
t('nothing empty is left behind', emptyObjectPaths(stripped), []);
|
|
|
|
/* ---- the placeholder one level up, which a SecurityHeadersConfig-only strip
|
|
turned into a hard abort (adversarial-reviewer, round 1) ------------- */
|
|
t(
|
|
'a top-level policy-config member is dropped',
|
|
withoutEmptyMembers({
|
|
Name: 'p',
|
|
CorsConfig: {},
|
|
SecurityHeadersConfig: stripped,
|
|
}),
|
|
{ Name: 'p', SecurityHeadersConfig: stripped },
|
|
);
|
|
|
|
/* ---- and the one BELOW that, which the first repair still aborted on
|
|
(adversarial-reviewer, round 2) -------------------------------------- */
|
|
t(
|
|
'a CorsConfig member is dropped, and the emptied CorsConfig with it',
|
|
withoutEmptyMembers({
|
|
Name: 'p',
|
|
CorsConfig: { AccessControlExposeHeaders: {} },
|
|
}),
|
|
{ Name: 'p' },
|
|
);
|
|
t(
|
|
'but a CorsConfig that still has content survives',
|
|
withoutEmptyMembers({
|
|
CorsConfig: { AccessControlExposeHeaders: {}, OriginOverride: false },
|
|
}),
|
|
{ CorsConfig: { OriginOverride: false } },
|
|
);
|
|
|
|
/* ---- things that must NOT be discarded ---------------------------------- */
|
|
t(
|
|
'an empty ARRAY is kept — {Quantity: 0, Items: []} is valid and common',
|
|
withoutEmptyMembers({ RemoveHeadersConfig: { Quantity: 0, Items: [] } }),
|
|
{ RemoveHeadersConfig: { Quantity: 0, Items: [] } },
|
|
);
|
|
t(
|
|
'false, 0, null and empty string are kept',
|
|
withoutEmptyMembers({ a: false, b: 0, c: null, d: '' }),
|
|
{ a: false, b: 0, c: null, d: '' },
|
|
);
|
|
t(
|
|
'array elements are recursed into but never removed',
|
|
withoutEmptyMembers({ Items: [{ Header: 'X', Sub: {} }, {}] }),
|
|
{ Items: [{ Header: 'X' }, {}] },
|
|
);
|
|
t(
|
|
'the custom-headers list the script builds is untouched',
|
|
withoutEmptyMembers({
|
|
CustomHeadersConfig: {
|
|
Quantity: 1,
|
|
Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }],
|
|
},
|
|
}),
|
|
{
|
|
CustomHeadersConfig: {
|
|
Quantity: 1,
|
|
Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }],
|
|
},
|
|
},
|
|
);
|
|
t('stripping is idempotent', withoutEmptyMembers(stripped), stripped);
|
|
|
|
/* ---- the drift comparison: {} and absent must normalise alike ------------
|
|
Round 1's repair stripped children but left `norm({})` as "{}" against
|
|
`norm(undefined)` as "null", which reported permanent, unrepairable drift on
|
|
the intake form's own path. */
|
|
const norm = (o) => {
|
|
const v = withoutEmptyMembers(o);
|
|
return JSON.stringify(isEmptyObject(v) ? null : (v ?? null));
|
|
};
|
|
t('norm({}) equals norm(undefined)', norm({}), norm(undefined));
|
|
t('norm({CorsConfig:{}}) equals norm({})', norm({ CorsConfig: {} }), norm({}));
|
|
t(
|
|
'but a real difference still differs',
|
|
norm({ a: 1 }) === norm({ a: 2 }),
|
|
false,
|
|
);
|
|
|
|
/* ---- emptyObjectPaths, the post-condition ------------------------------- */
|
|
t(
|
|
'reports the incident path',
|
|
emptyObjectPaths({ SecurityHeadersConfig: LIVE_SECURITY_HEADERS }),
|
|
['SecurityHeadersConfig.ContentSecurityPolicy'],
|
|
);
|
|
t(
|
|
'reports round 2s deeper path',
|
|
emptyObjectPaths({ CorsConfig: { AccessControlExposeHeaders: {} } }),
|
|
['CorsConfig.AccessControlExposeHeaders'],
|
|
);
|
|
t(
|
|
'reports an empty object inside an array, with its index',
|
|
emptyObjectPaths({ Items: [{ Header: 'X' }, {}] }),
|
|
['Items[1]'],
|
|
);
|
|
t(
|
|
'reports every one, not just the first',
|
|
emptyObjectPaths({ a: {}, b: { c: {} } }),
|
|
['a', 'b.c'],
|
|
);
|
|
t('silent on an empty array', emptyObjectPaths({ a: [] }), []);
|
|
t(
|
|
'silent on null, undefined and primitives',
|
|
emptyObjectPaths({ a: null, b: undefined, c: 1, d: 'x', e: true }),
|
|
[],
|
|
);
|
|
t('names the root when the whole config is empty', emptyObjectPaths({}), [
|
|
'(root)',
|
|
]);
|
|
|
|
/* ---- the invariant the two functions exist to hold together ------------- */
|
|
t(
|
|
'THE INVARIANT: nothing survives the strip that the assertion would report',
|
|
emptyObjectPaths(
|
|
withoutEmptyMembers({
|
|
Name: 'adr-sml-pdf-noindex',
|
|
SecurityHeadersConfig: LIVE_SECURITY_HEADERS,
|
|
CorsConfig: { AccessControlExposeHeaders: {} },
|
|
ServerTimingHeadersConfig: {},
|
|
CustomHeadersConfig: {
|
|
Quantity: 1,
|
|
Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }],
|
|
},
|
|
}),
|
|
),
|
|
[],
|
|
);
|
|
|
|
/* ---- 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 - ')}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
console.log(`policy-shapes: ${pass} of ${pass} cases pass`);
|