1import * as Lambda from "@/AWS/Lambda";
2import * as WAFv2 from "@/AWS/WAFv2";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Result from "effect/Result";
7import { HttpServerRequest } from "effect/http/HttpServerRequest";
8import * as HttpServerResponse from "effect/http/HttpServerResponse";
9import path from "pathe";
10
11const main = path.resolve(import.meta.dirname, "handler.ts");
12
13export class Wafv2BindingsFunction extends Lambda.Function<Lambda.Function>()(
14 "Wafv2BindingsFunction",
15) {}
16
17export default Wafv2BindingsFunction.make(
18 {
19 main,
20 functionUrl: true,
21 timeout: Duration.seconds(120),
22 },
23 Effect.gen(function* () {
24 // Cheap, fast-provisioning WAF entities that exercise every binding
25 // scope: an IP set (dynamic block list), a web ACL with a rate-based
26 // rule, and a rule group (permission-policy interface).
27 const ipSet = yield* WAFv2.IPSet("BindingsIpSet", {
28 addresses: ["192.0.2.44/32"],
29 description: "bindings fixture",
30 });
31 const webAcl = yield* WAFv2.WebACL("BindingsAcl", {
32 defaultAction: { Allow: {} },
33 rules: [
34 {
35 Name: "rate-limit",
36 Priority: 0,
37 Statement: {
38 RateBasedStatement: { Limit: 100, AggregateKeyType: "IP" },
39 },
40 Action: { Block: {} },
41 VisibilityConfig: {
42 SampledRequestsEnabled: true,
43 CloudWatchMetricsEnabled: true,
44 MetricName: "rate-limit",
45 },
46 },
47 ],
48 });
49 const ruleGroup = yield* WAFv2.RuleGroup("BindingsRuleGroup", {
50 capacity: 10,
51 });
52
53 // The rule group ARN accessor — resolved per-request to derive the
54 // account id for the permission-policy round trip.
55 const ruleGroupArn = yield* ruleGroup.ruleGroupArn;
56
57 const bound = {
58 getIPSet: yield* WAFv2.GetIPSet(ipSet),
59 updateIPSet: yield* WAFv2.UpdateIPSet(ipSet),
60 getSampledRequests: yield* WAFv2.GetSampledRequests(webAcl),
61 getRateBasedStatementManagedKeys:
62 yield* WAFv2.GetRateBasedStatementManagedKeys(webAcl),
63 listResourcesForWebACL: yield* WAFv2.ListResourcesForWebACL(webAcl),
64 getTopPathStatisticsByTraffic:
65 yield* WAFv2.GetTopPathStatisticsByTraffic(webAcl),
66 getPermissionPolicy: yield* WAFv2.GetPermissionPolicy(ruleGroup),
67 putPermissionPolicy: yield* WAFv2.PutPermissionPolicy(ruleGroup),
68 deletePermissionPolicy: yield* WAFv2.DeletePermissionPolicy(ruleGroup),
69 checkCapacity: yield* WAFv2.CheckCapacity(),
70 createAPIKey: yield* WAFv2.CreateAPIKey(),
71 getDecryptedAPIKey: yield* WAFv2.GetDecryptedAPIKey(),
72 listAPIKeys: yield* WAFv2.ListAPIKeys(),
73 deleteAPIKey: yield* WAFv2.DeleteAPIKey(),
74 describeManagedRuleGroup: yield* WAFv2.DescribeManagedRuleGroup(),
75 listAvailableManagedRuleGroups:
76 yield* WAFv2.ListAvailableManagedRuleGroups(),
77 listAvailableManagedRuleGroupVersions:
78 yield* WAFv2.ListAvailableManagedRuleGroupVersions(),
79 describeAllManagedProducts: yield* WAFv2.DescribeAllManagedProducts(),
80 describeManagedProductsByVendor:
81 yield* WAFv2.DescribeManagedProductsByVendor(),
82 getWebACLForResource: yield* WAFv2.GetWebACLForResource(),
83 };
84
85 return {
86 fetch: Effect.gen(function* () {
87 const request = yield* HttpServerRequest;
88 const url = new URL(request.originalUrl);
89 const pathname = url.pathname;
90
91 if (request.method === "GET" && pathname === "/bindings") {
92 return yield* HttpServerResponse.json({
93 bound: Object.keys(bound),
94 });
95 }
96
97 // IP-set-scoped: Name/Scope/Id injected.
98 if (request.method === "GET" && pathname === "/ip-set") {
99 const response = yield* bound.getIPSet();
100 return yield* HttpServerResponse.json({
101 addresses: [...(response.IPSet?.Addresses ?? [])].sort(),
102 description: response.IPSet?.Description ?? "",
103 });
104 }
105
106 // Dynamic block list: full-replacement update, LockToken handled
107 // inside the binding.
108 if (request.method === "POST" && pathname === "/ip-set") {
109 const body = (yield* request.json) as { addresses: string[] };
110 yield* bound.updateIPSet({ addresses: body.addresses });
111 const after = yield* bound.getIPSet();
112 return yield* HttpServerResponse.json({
113 addresses: [...(after.IPSet?.Addresses ?? [])].sort(),
114 });
115 }
116
117 // Web-ACL-scoped: WebAclArn + Scope injected.
118 if (request.method === "GET" && pathname === "/sampled") {
119 const now = yield* Effect.sync(() => new Date());
120 const response = yield* bound.getSampledRequests({
121 RuleMetricName: "rate-limit",
122 TimeWindow: {
123 StartTime: new Date(now.getTime() - 60 * 60 * 1000),
124 EndTime: now,
125 },
126 MaxItems: 100,
127 });
128 return yield* HttpServerResponse.json({
129 sampled: (response.SampledRequests ?? []).length,
130 populationSize: response.PopulationSize ?? 0,
131 });
132 }
133
134 // Web-ACL-scoped: Scope + WebACLName + WebACLId injected.
135 if (request.method === "GET" && pathname === "/rate-keys") {
136 const response = yield* bound.getRateBasedStatementManagedKeys({
137 RuleName: "rate-limit",
138 });
139 return yield* HttpServerResponse.json({
140 v4: (response.ManagedKeysIPV4?.Addresses ?? []).length,
141 v4Version: response.ManagedKeysIPV4?.IPAddressVersion ?? null,
142 });
143 }
144
145 if (request.method === "GET" && pathname === "/resources") {
146 const response = yield* bound.listResourcesForWebACL();
147 return yield* HttpServerResponse.json({
148 resources: response.ResourceArns ?? [],
149 });
150 }
151
152 // Pricing-plan-gated: report the typed outcome instead of dying.
153 if (request.method === "GET" && pathname === "/top-paths") {
154 const now = yield* Effect.sync(() => new Date());
155 const result = yield* Effect.result(
156 bound.getTopPathStatisticsByTraffic({
157 TimeWindow: {
158 StartTime: new Date(now.getTime() - 60 * 60 * 1000),
159 EndTime: now,
160 },
161 Limit: 10,
162 NumberOfTopTrafficBotsPerPath: 3,
163 }),
164 );
165 return yield* HttpServerResponse.json(
166 Result.isSuccess(result)
167 ? { ok: true, tag: null }
168 : { ok: false, tag: result.failure._tag },
169 );
170 }
171
172 // Permission-policy round trip on the rule group ARN.
173 if (request.method === "GET" && pathname === "/permission-policy") {
174 const before = yield* bound
175 .getPermissionPolicy()
176 .pipe(
177 Effect.catchTag("WAFNonexistentItemException", () =>
178 Effect.succeed({ Policy: undefined }),
179 ),
180 );
181 const arn = yield* ruleGroupArn;
182 const account = arn.split(":")[4];
183 // The policy must NOT include a Resource parameter — the resource
184 // is implied by the ResourceArn the binding injects.
185 yield* bound.putPermissionPolicy({
186 Policy: JSON.stringify({
187 Version: "2012-10-17",
188 Statement: [
189 {
190 Effect: "Allow",
191 Principal: { AWS: `arn:aws:iam::${account}:root` },
192 Action: [
193 "wafv2:CreateWebACL",
194 "wafv2:UpdateWebACL",
195 "wafv2:PutFirewallManagerRuleGroups",
196 ],
197 },
198 ],
199 }),
200 });
201 const after = yield* bound.getPermissionPolicy();
202 yield* bound.deletePermissionPolicy();
203 return yield* HttpServerResponse.json({
204 beforePolicy: before.Policy ?? null,
205 hasPolicy: typeof after.Policy === "string",
206 deleted: true,
207 });
208 }
209
210 // Account-level: WCU cost of a simple geo-match rule.
211 if (request.method === "GET" && pathname === "/capacity") {
212 const response = yield* bound.checkCapacity({
213 Scope: "REGIONAL",
214 Rules: [
215 {
216 Name: "cap-probe",
217 Priority: 0,
218 Statement: { GeoMatchStatement: { CountryCodes: ["US"] } },
219 Action: { Block: {} },
220 VisibilityConfig: {
221 SampledRequestsEnabled: false,
222 CloudWatchMetricsEnabled: false,
223 MetricName: "cap-probe",
224 },
225 },
226 ],
227 });
228 return yield* HttpServerResponse.json({
229 capacity: response.Capacity ?? 0,
230 });
231 }
232
233 // CAPTCHA API-key lifecycle: mint, list, decrypt, delete.
234 if (request.method === "GET" && pathname === "/api-keys") {
235 const created = yield* bound.createAPIKey({
236 Scope: "REGIONAL",
237 TokenDomains: ["example.com"],
238 });
239 const apiKey = created.APIKey ?? "";
240 const listed = yield* bound.listAPIKeys({ Scope: "REGIONAL" });
241 const decrypted = yield* bound.getDecryptedAPIKey({
242 Scope: "REGIONAL",
243 APIKey: apiKey,
244 });
245 yield* bound.deleteAPIKey({ Scope: "REGIONAL", APIKey: apiKey });
246 return yield* HttpServerResponse.json({
247 created: apiKey.length > 0,
248 listed: (listed.APIKeySummaries ?? []).length,
249 domains: decrypted.TokenDomains ?? [],
250 deleted: true,
251 });
252 }
253
254 // Managed rule group catalog.
255 if (request.method === "GET" && pathname === "/managed") {
256 const groups = yield* bound.listAvailableManagedRuleGroups({
257 Scope: "REGIONAL",
258 Limit: 20,
259 });
260 const described = yield* bound.describeManagedRuleGroup({
261 VendorName: "AWS",
262 Name: "AWSManagedRulesCommonRuleSet",
263 Scope: "REGIONAL",
264 });
265 const versions = yield* bound.listAvailableManagedRuleGroupVersions({
266 VendorName: "AWS",
267 Name: "AWSManagedRulesCommonRuleSet",
268 Scope: "REGIONAL",
269 Limit: 5,
270 });
271 return yield* HttpServerResponse.json({
272 groups: (groups.ManagedRuleGroups ?? []).length,
273 capacity: described.Capacity ?? 0,
274 currentDefaultVersion: versions.CurrentDefaultVersion ?? null,
275 });
276 }
277
278 if (request.method === "GET" && pathname === "/products") {
279 const all = yield* bound.describeAllManagedProducts({
280 Scope: "REGIONAL",
281 });
282 const aws = yield* bound.describeManagedProductsByVendor({
283 VendorName: "AWS",
284 Scope: "REGIONAL",
285 });
286 return yield* HttpServerResponse.json({
287 all: (all.ManagedProducts ?? []).length,
288 aws: (aws.ManagedProducts ?? []).length,
289 });
290 }
291
292 // Look up the web ACL of a (nonexistent) ALB — the typed
293 // WAFNonexistentItemException proves grant + wiring.
294 if (request.method === "GET" && pathname === "/waf-for-resource") {
295 const ipSetArn = (yield* bound.getIPSet()).IPSet?.ARN ?? "";
296 const [, , , region, account] = ipSetArn.split(":");
297 const response = yield* bound
298 .getWebACLForResource({
299 ResourceArn: `arn:aws:elasticloadbalancing:${region}:${account}:loadbalancer/app/wafv2-bindings-missing/0123456789abcdef`,
300 })
301 .pipe(
302 Effect.catchTag("WAFNonexistentItemException", () =>
303 Effect.succeed({ WebACL: undefined }),
304 ),
305 );
306 return yield* HttpServerResponse.json({
307 found: response.WebACL !== undefined,
308 });
309 }
310
311 return yield* HttpServerResponse.json(
312 { error: "Not found", method: request.method, pathname },
313 { status: 404 },
314 );
315 }).pipe(Effect.orDie),
316 };
317 }).pipe(
318 Effect.provide(
319 Layer.mergeAll(
320 WAFv2.GetIPSetHttp,
321 WAFv2.UpdateIPSetHttp,
322 WAFv2.GetSampledRequestsHttp,
323 WAFv2.GetRateBasedStatementManagedKeysHttp,
324 WAFv2.ListResourcesForWebACLHttp,
325 WAFv2.GetTopPathStatisticsByTrafficHttp,
326 WAFv2.GetPermissionPolicyHttp,
327 WAFv2.PutPermissionPolicyHttp,
328 WAFv2.DeletePermissionPolicyHttp,
329 WAFv2.CheckCapacityHttp,
330 WAFv2.CreateAPIKeyHttp,
331 WAFv2.GetDecryptedAPIKeyHttp,
332 WAFv2.ListAPIKeysHttp,
333 WAFv2.DeleteAPIKeyHttp,
334 WAFv2.DescribeManagedRuleGroupHttp,
335 WAFv2.ListAvailableManagedRuleGroupsHttp,
336 WAFv2.ListAvailableManagedRuleGroupVersionsHttp,
337 WAFv2.DescribeAllManagedProductsHttp,
338 WAFv2.DescribeManagedProductsByVendorHttp,
339 WAFv2.GetWebACLForResourceHttp,
340 ),
341 ),
342 ),
343);