| 1 | import * as Lambda from "@/AWS/Lambda"; |
| 2 | import * as WAFv2 from "@/AWS/WAFv2"; |
| 3 | import * as Duration from "effect/Duration"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Result from "effect/Result"; |
| 7 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 8 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 9 | import path from "pathe"; |
| 10 | |
| 11 | const main = path.resolve(import.meta.dirname, "handler.ts"); |
| 12 | |
| 13 | export class Wafv2BindingsFunction extends Lambda.Function<Lambda.Function>()( |
| 14 | "Wafv2BindingsFunction", |
| 15 | ) {} |
| 16 | |
| 17 | export 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 | ); |