Skip to content

Commit bb7820d

Browse files
committed
fix(wafv2): decode the CFn-only SearchStringBase64 into the SDK SearchString
WAFv2WebACLProvider forwarded the Rules blob raw. SearchStringBase64 exists only in CloudFormation -- the SDK model carries a single SearchString blob member -- so the AWS SDK v3 serializer dropped the unknown key and CreateWebACL failed validation on the missing required SearchString. A loud create failure for every template using the base64 form. ByteMatchStatement is nestable, so the conversion walks the whole statement tree: NotStatement.Statement, And/OrStatement.Statements[], and the ScopeDownStatement of both RateBasedStatement and ManagedRuleGroupStatement -- the complete set of nested-statement members the SDK Statement union declares. The walk rebuilds every level, so the caller's properties object is never mutated. Plain SearchString values are left untouched: the serializer accepts a string at a blob member and encodes it, which is the existing working behavior. PreParseTextTransformations, Monetize and PriceMultiplier have no member in the installed SDK model, so no mapping exists to write -- the fix is an SDK bump, after which the spellings already match. They cannot go in unhandledByDesign either, which is top-level property granularity, and all three nest inside the genuinely-handled Rules. Their drop is made loud with a warning naming them on create and update instead of staying silent. The wafv2 integ fixture now carries two SearchStringBase64 ByteMatchStatements -- one under the rate-based rule's ScopeDownStatement, one under a NotStatement -- so the deploy itself is the regression signal: pre-fix CreateWebACL rejects the stack. Closes #1389
1 parent 98dc56b commit bb7820d

4 files changed

Lines changed: 573 additions & 3 deletions

File tree

docs/_generated/integ-last-run.tsv

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,5 +268,5 @@ vpc-lambda 2026-07-26T16:53:02Z PASS 386 standard 0727 P0 sweep (ec2-provider ch
268268
vpc-lambda-cr-race 2026-07-24T11:03:24Z PASS 432 standard 0724b sweep staleness; 9 del 0 err 0 orphans incl hyperplane ENI
269269
vpc-lookup 2026-07-24T10:55:09Z PASS 20 standard 0724b sweep staleness; context-provider loop ok; 1 del 0 err 0 orphans
270270
vpc-nat-gateway 2026-07-22T08:08:02Z PASS standard TTL-refresh sweep; standard flow (classifier-approved direct deploy/destroy); 21 created / 21 deleted 0 err; NAT GW + VPC + EIP + ENI all gone, state gone
271-
wafv2 2026-07-26T19:36:49Z PASS 58 standard 0727b sweep-b11 staleness re-run (rc=0); account clean
271+
wafv2 2026-08-09T05:39:42Z PASS 100 standard #1389 SearchStringBase64 rules added (nested + NotStatement); GetWebACL round-trip ok, 7 del/2 retained/0 err, 0 orphans
272272
wait-condition-handle 2026-07-31T06:19:56Z PASS 44 verify.sh TTL-refresh sweep re-run; rc ok, orph clean

src/provisioning/providers/wafv2-provider.ts

Lines changed: 173 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,156 @@ function sanitizeDescription(value: unknown): string | undefined {
5252
return value as string;
5353
}
5454

55+
function isPlainObject(value: unknown): value is Record<string, unknown> {
56+
return typeof value === 'object' && value !== null && !Array.isArray(value);
57+
}
58+
59+
/**
60+
* CFn `Rules` members that have NO counterpart in the installed
61+
* `@aws-sdk/client-wafv2` model (verified: zero hits in
62+
* `dist-types/models/models_0.d.ts`).
63+
*
64+
* The AWS SDK v3 serializer drops unknown members, so these silently do
65+
* not reach AWS no matter what cdkd forwards. There is no mapping cdkd
66+
* can invent — the fix is an SDK bump, after which the spellings already
67+
* match and no code here has to change. Until then the drop is made LOUD
68+
* (`warnOnSdkUnsupportedRuleKeys`) instead of silent.
69+
*
70+
* They cannot be declared in `unhandledByDesign` either: that map is
71+
* TOP-LEVEL CFn property granularity, and all three are nested inside
72+
* `Rules`, which the provider genuinely handles.
73+
*/
74+
const SDK_UNSUPPORTED_RULE_KEYS: readonly string[] = [
75+
// TextTransformation.PreParseTextTransformations
76+
'PreParseTextTransformations',
77+
// Rule action Monetize / its PriceMultiplier
78+
'Monetize',
79+
'PriceMultiplier',
80+
];
81+
82+
/**
83+
* Convert a CFn `ByteMatchStatement` into the SDK shape.
84+
*
85+
* `SearchStringBase64` exists ONLY in CloudFormation — the SDK models
86+
* carry a single `SearchString: Uint8Array | undefined` blob member
87+
* (`@aws-sdk/client-wafv2/dist-types/models/models_0.d.ts:1034`).
88+
* Forwarding the CFn blob raw made the serializer drop the unknown key
89+
* and `CreateWebACL` then failed validation on the missing required
90+
* `SearchString` (issue #1389).
91+
*
92+
* The decoded bytes are passed as a `Uint8Array`, matching the declared
93+
* member type: the JSON serializer base64-encodes a blob member on the
94+
* wire, so decoding here reproduces byte-for-byte what CloudFormation
95+
* sends. Plain `SearchString` values are deliberately left untouched —
96+
* the same serializer accepts a string at a blob member and UTF-8 +
97+
* base64 encodes it, which is exactly the existing (working) behavior.
98+
*
99+
* Precedence when a template carries BOTH keys: `SearchStringBase64`
100+
* wins. CloudFormation treats them as mutually exclusive and rejects
101+
* such a template, so any choice is arbitrary; the explicit-encoding
102+
* form is preferred because it is the only one that can express
103+
* non-UTF-8 bytes, so honoring it never loses information.
104+
*/
105+
function toSdkByteMatchStatement(
106+
byteMatchStatement: Record<string, unknown>
107+
): Record<string, unknown> {
108+
const encoded = byteMatchStatement['SearchStringBase64'];
109+
if (typeof encoded !== 'string') return byteMatchStatement;
110+
111+
const converted: Record<string, unknown> = { ...byteMatchStatement };
112+
delete converted['SearchStringBase64'];
113+
converted['SearchString'] = Uint8Array.from(Buffer.from(encoded, 'base64'));
114+
return converted;
115+
}
116+
117+
/**
118+
* Recursively convert a CFn `Statement` tree into the SDK shape.
119+
*
120+
* `ByteMatchStatement` is nestable, so the walk has to cover every
121+
* recursion point the SDK `Statement` union declares:
122+
* - `NotStatement.Statement` (single nested Statement)
123+
* - `AndStatement.Statements[]` (Statement array)
124+
* - `OrStatement.Statements[]` (Statement array)
125+
* - `RateBasedStatement.ScopeDownStatement`
126+
* - `ManagedRuleGroupStatement.ScopeDownStatement`
127+
*
128+
* That list mirrors the SDK model exactly — `RuleGroupReferenceStatement`
129+
* deliberately is NOT in it, because it declares no nested statement
130+
* member (only `ARN` / `ExcludedRules` / `RuleActionOverrides`). Extend
131+
* the list if AWS adds another nestable member.
132+
*
133+
* The caller's object is never mutated — every level is rebuilt.
134+
*/
135+
function toSdkStatement(statement: Record<string, unknown>): Record<string, unknown> {
136+
const converted: Record<string, unknown> = { ...statement };
137+
138+
const byteMatchStatement = converted['ByteMatchStatement'];
139+
if (isPlainObject(byteMatchStatement)) {
140+
converted['ByteMatchStatement'] = toSdkByteMatchStatement(byteMatchStatement);
141+
}
142+
143+
const notStatement = converted['NotStatement'];
144+
if (isPlainObject(notStatement)) {
145+
const nested = notStatement['Statement'];
146+
if (isPlainObject(nested)) {
147+
converted['NotStatement'] = { ...notStatement, Statement: toSdkStatement(nested) };
148+
}
149+
}
150+
151+
for (const key of ['AndStatement', 'OrStatement']) {
152+
const combined = converted[key];
153+
if (!isPlainObject(combined)) continue;
154+
const nested = combined['Statements'];
155+
if (!Array.isArray(nested)) continue;
156+
converted[key] = {
157+
...combined,
158+
Statements: nested.map((item) => (isPlainObject(item) ? toSdkStatement(item) : item)),
159+
};
160+
}
161+
162+
for (const key of ['RateBasedStatement', 'ManagedRuleGroupStatement']) {
163+
const scoping = converted[key];
164+
if (!isPlainObject(scoping)) continue;
165+
const nested = scoping['ScopeDownStatement'];
166+
if (!isPlainObject(nested)) continue;
167+
converted[key] = { ...scoping, ScopeDownStatement: toSdkStatement(nested) };
168+
}
169+
170+
return converted;
171+
}
172+
173+
/**
174+
* Convert the CFn `Rules` blob into the SDK `Rule[]` shape.
175+
*
176+
* Absent / non-array input degrades to `[]`, matching the previous
177+
* `(properties['Rules'] as Rule[]) || []` behavior.
178+
*/
179+
function toSdkRules(rules: unknown): Rule[] {
180+
if (!Array.isArray(rules)) return [];
181+
return rules.map((rule) => {
182+
if (!isPlainObject(rule)) return rule as unknown as Rule;
183+
const statement = rule['Statement'];
184+
if (!isPlainObject(statement)) return rule as unknown as Rule;
185+
return { ...rule, Statement: toSdkStatement(statement) } as unknown as Rule;
186+
});
187+
}
188+
189+
/**
190+
* Collect every {@link SDK_UNSUPPORTED_RULE_KEYS} member present anywhere
191+
* in the CFn `Rules` blob, so the caller can report the silent drop.
192+
*/
193+
function collectSdkUnsupportedRuleKeys(value: unknown, found: Set<string>): void {
194+
if (Array.isArray(value)) {
195+
for (const item of value) collectSdkUnsupportedRuleKeys(item, found);
196+
return;
197+
}
198+
if (!isPlainObject(value)) return;
199+
for (const [key, nested] of Object.entries(value)) {
200+
if (SDK_UNSUPPORTED_RULE_KEYS.includes(key)) found.add(key);
201+
collectSdkUnsupportedRuleKeys(nested, found);
202+
}
203+
}
204+
55205
/**
56206
* Parse WAFv2 WebACL ARN to extract Id, Name, and Scope.
57207
*
@@ -140,6 +290,8 @@ export class WAFv2WebACLProvider implements ResourceProvider {
140290
generateResourceName(logicalId, { maxLength: 128 });
141291
const scope = ((properties['Scope'] as string) || 'REGIONAL') as Scope;
142292

293+
this.warnOnSdkUnsupportedRuleKeys(logicalId, properties['Rules']);
294+
143295
try {
144296
// Build tags
145297
const tags: Tag[] = [];
@@ -156,7 +308,7 @@ export class WAFv2WebACLProvider implements ResourceProvider {
156308
Scope: scope,
157309
DefaultAction: properties['DefaultAction'] as DefaultAction,
158310
Description: sanitizeDescription(properties['Description']),
159-
Rules: (properties['Rules'] as Rule[]) || [],
311+
Rules: toSdkRules(properties['Rules']),
160312
VisibilityConfig: properties['VisibilityConfig'] as VisibilityConfig,
161313
...(tags.length > 0 && { Tags: tags }),
162314
CustomResponseBodies: properties['CustomResponseBodies'] as
@@ -212,6 +364,8 @@ export class WAFv2WebACLProvider implements ResourceProvider {
212364
): Promise<ResourceUpdateResult> {
213365
this.logger.debug(`Updating WAFv2 WebACL ${logicalId}: ${physicalId}`);
214366

367+
this.warnOnSdkUnsupportedRuleKeys(logicalId, properties['Rules']);
368+
215369
try {
216370
const { id, name, scope } = parseWebACLArn(physicalId);
217371

@@ -237,7 +391,7 @@ export class WAFv2WebACLProvider implements ResourceProvider {
237391
LockToken: lockToken,
238392
DefaultAction: properties['DefaultAction'] as DefaultAction,
239393
Description: sanitizeDescription(properties['Description']),
240-
Rules: (properties['Rules'] as Rule[]) || [],
394+
Rules: toSdkRules(properties['Rules']),
241395
VisibilityConfig: properties['VisibilityConfig'] as VisibilityConfig,
242396
CustomResponseBodies: properties['CustomResponseBodies'] as
243397
| Record<string, CustomResponseBody>
@@ -348,6 +502,23 @@ export class WAFv2WebACLProvider implements ResourceProvider {
348502
}
349503
}
350504

505+
/**
506+
* Report every CFn `Rules` member the installed `@aws-sdk/client-wafv2`
507+
* model has no counterpart for, so the drop is visible instead of
508+
* silent. See {@link SDK_UNSUPPORTED_RULE_KEYS} for why cdkd cannot map
509+
* them and what makes them work (an SDK bump).
510+
*/
511+
private warnOnSdkUnsupportedRuleKeys(logicalId: string, rules: unknown): void {
512+
const found = new Set<string>();
513+
collectSdkUnsupportedRuleKeys(rules, found);
514+
if (found.size === 0) return;
515+
516+
const names = [...found].sort().join(', ');
517+
this.logger.warn(
518+
`WAFv2 WebACL ${logicalId}: rule property ${names} has no member in the installed AWS SDK WAFv2 model and will NOT be sent to AWS. Upgrade cdkd once its @aws-sdk/client-wafv2 dependency carries the property.`
519+
);
520+
}
521+
351522
/**
352523
* Apply a diff between old and new CFn-shape Tags arrays via WAFv2's
353524
* `TagResource` / `UntagResource` APIs (keyed by `ResourceARN`).

tests/integration/wafv2/lib/wafv2-stack.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ import * as apigateway from 'aws-cdk-lib/aws-apigateway';
1111
* - AWS::WAFv2::WebACL
1212
* - AWS::WAFv2::WebACLAssociation
1313
* - AWS::ApiGateway::RestApi
14+
*
15+
* The WebACL also carries two ByteMatchStatements written with the CFn-only
16+
* `SearchStringBase64` key (issue #1389) -- one nested under the rate-based
17+
* rule's ScopeDownStatement, one under a NotStatement -- so the deploy itself
18+
* is the regression signal: pre-fix the key was dropped by the SDK serializer
19+
* and CreateWebACL failed validation on the missing required SearchString.
1420
*/
1521
export class Wafv2Stack extends cdk.Stack {
1622
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
@@ -50,6 +56,47 @@ export class Wafv2Stack extends cdk.Stack {
5056
rateBasedStatement: {
5157
limit: 2000,
5258
aggregateKeyType: 'IP',
59+
// NESTED ByteMatchStatement (issue #1389): the scope-down of a
60+
// RateBasedStatement is one of the statement-tree recursion
61+
// points the base64 conversion has to walk. Base64 of
62+
// 'cdkd-scoped'.
63+
scopeDownStatement: {
64+
byteMatchStatement: {
65+
searchStringBase64: 'Y2RrZC1zY29wZWQ=',
66+
fieldToMatch: { uriPath: {} },
67+
positionalConstraint: 'CONTAINS',
68+
textTransformations: [{ priority: 0, type: 'NONE' }],
69+
},
70+
},
71+
},
72+
},
73+
},
74+
{
75+
// Issue #1389: SearchStringBase64 exists ONLY in CloudFormation --
76+
// the SDK models carry a single SearchString blob member -- so the
77+
// AWS SDK v3 serializer dropped the unknown key and CreateWebACL
78+
// failed validation on the missing required SearchString. This rule
79+
// therefore makes the whole deploy fail without the fix, which is
80+
// exactly the regression signal we want from a standard-flow fixture.
81+
// Base64 of 'cdkd-blocked'.
82+
name: 'Base64ByteMatchRule',
83+
priority: 2,
84+
action: { block: {} },
85+
visibilityConfig: {
86+
cloudWatchMetricsEnabled: true,
87+
metricName: 'Base64ByteMatchRule',
88+
sampledRequestsEnabled: true,
89+
},
90+
statement: {
91+
notStatement: {
92+
statement: {
93+
byteMatchStatement: {
94+
searchStringBase64: 'Y2RrZC1ibG9ja2Vk',
95+
fieldToMatch: { singleHeader: { Name: 'user-agent' } },
96+
positionalConstraint: 'CONTAINS',
97+
textTransformations: [{ priority: 0, type: 'LOWERCASE' }],
98+
},
99+
},
53100
},
54101
},
55102
},

0 commit comments

Comments
 (0)