dcl-ops-lib 9.3.1 → 9.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.d.ts CHANGED
@@ -23,6 +23,7 @@ export * from "./lambda";
23
23
  export * from "./network";
24
24
  export * from "./nlb";
25
25
  export * from "./prometheus";
26
+ export * from "./rateLimiting";
26
27
  export * from "./scheduledTaskBase";
27
28
  export * from "./secrets";
28
29
  export * from "./slack";
package/index.js CHANGED
@@ -41,6 +41,7 @@ __exportStar(require("./lambda"), exports);
41
41
  __exportStar(require("./network"), exports);
42
42
  __exportStar(require("./nlb"), exports);
43
43
  __exportStar(require("./prometheus"), exports);
44
+ __exportStar(require("./rateLimiting"), exports);
44
45
  __exportStar(require("./scheduledTaskBase"), exports);
45
46
  __exportStar(require("./secrets"), exports);
46
47
  __exportStar(require("./slack"), exports);
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "dcl-ops-lib",
3
- "version": "9.3.1",
3
+ "version": "9.4.0",
4
4
  "scripts": {
5
5
  "build": "tsc && cp bin/* . && node test.js",
6
+ "test": "tsc -p tsconfig.test.json && ENVIRONMENT=dev node bin-test/rateLimiting.test.js",
6
7
  "clean": "rm *.d.ts *.js *.js.map"
7
8
  },
8
9
  "files": [
@@ -0,0 +1,84 @@
1
+ import * as cf from "@pulumi/cloudflare";
2
+ export type RateLimitRule = {
3
+ /** The maximum number of requests allowed within the period. */
4
+ requestsPerPeriod: number;
5
+ /** The time window in seconds to count requests (10, 60, 120, 300, or 600). */
6
+ period: 10 | 60 | 120 | 300 | 600;
7
+ /** The action to take when the threshold is exceeded. */
8
+ action: "block" | "challenge" | "managed_challenge" | "js_challenge" | "log";
9
+ /**
10
+ * How long (in seconds) to block requests after the threshold is exceeded.
11
+ * Required when action is "block". Must be >= period. Ignored for other actions.
12
+ */
13
+ mitigationTimeout?: number;
14
+ /**
15
+ * A Cloudflare Firewall Rules expression to match requests.
16
+ * The hostname condition is prepended automatically.
17
+ * Examples:
18
+ * - `http.request.uri.path eq "/login"` — matches a single path
19
+ * - `http.request.uri.path matches "^/api/"` — matches a path prefix with regex
20
+ * - `http.request.uri.path eq "/login" and http.request.method eq "POST"` — path + method
21
+ *
22
+ * If not provided, matches all traffic for the domain.
23
+ * @see https://developers.cloudflare.com/ruleset-engine/rules-language/
24
+ */
25
+ expression?: string;
26
+ /** Optional description for the rule. */
27
+ description?: string;
28
+ /**
29
+ * Whether to count all requests (including those served from CF cache) towards
30
+ * the rate limit threshold. Defaults to true.
31
+ *
32
+ * When false, only requests forwarded to the origin count. An attacker hitting
33
+ * a cached endpoint would not trigger the rate limit.
34
+ */
35
+ countCachedRequests?: boolean;
36
+ };
37
+ export type ServiceRateLimitConfig = {
38
+ /** The service name, used for resource naming and descriptions. */
39
+ serviceName: string;
40
+ /** The public hostname for this service (e.g. "my-service.decentraland.org"). */
41
+ hostname: string;
42
+ /** The rate limiting rules for this service. */
43
+ rules: RateLimitRule[];
44
+ };
45
+ /**
46
+ * Validates a single rate limit rule and returns a Cloudflare Ruleset rule object.
47
+ *
48
+ * @param globalIndex - A unique index across all rules in the ruleset, used for
49
+ * resource naming and error messages.
50
+ * @throws if the rule configuration is invalid.
51
+ * @internal Exported for testing.
52
+ */
53
+ export declare function buildRulesetRule(serviceName: string, hostname: string, rule: RateLimitRule, globalIndex: number): {
54
+ action: string;
55
+ expression: string;
56
+ description: string;
57
+ enabled: boolean;
58
+ ratelimit: {
59
+ characteristics: string[];
60
+ period: 120 | 60 | 10 | 300 | 600;
61
+ requestsPerPeriod: number;
62
+ mitigationTimeout: number;
63
+ requestsToOrigin: boolean;
64
+ };
65
+ };
66
+ /** @internal Reset module state for testing. */
67
+ export declare function _resetForTesting(): void;
68
+ /**
69
+ * Creates a single zone-level Cloudflare Ruleset containing rate limiting rules
70
+ * aggregated from all services. This must be called from a centralized Pulumi
71
+ * stack — not from individual service stacks — because Cloudflare only supports
72
+ * one `http_ratelimit` phase ruleset per zone.
73
+ *
74
+ * This function must only be called once per Pulumi program. A second call will
75
+ * throw to prevent silent overwrites of the zone-level ruleset.
76
+ *
77
+ * Rules are ordered so that rules with a custom expression (narrower scope) are
78
+ * evaluated before hostname-only rules (broader scope). Within each group, the
79
+ * original insertion order is preserved.
80
+ *
81
+ * @param services - Array of service rate limiting configurations to aggregate.
82
+ * @returns The created Ruleset, or undefined if no services have rate limiting rules.
83
+ */
84
+ export declare function createZoneRateLimitRuleset(services: ServiceRateLimitConfig[]): Promise<cf.Ruleset | undefined>;
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createZoneRateLimitRuleset = exports._resetForTesting = exports.buildRulesetRule = void 0;
4
+ const cf = require("@pulumi/cloudflare");
5
+ const domain_1 = require("./domain");
6
+ const cloudflare_1 = require("./cloudflare");
7
+ const ACTION_MAP = {
8
+ block: "block",
9
+ challenge: "challenge",
10
+ managed_challenge: "managedChallenge",
11
+ js_challenge: "jsChallenge",
12
+ log: "log",
13
+ };
14
+ /**
15
+ * Matches a valid hostname: labels separated by dots, each label alphanumeric
16
+ * or hyphen (no leading/trailing hyphen), ending in a TLD of 2+ chars.
17
+ */
18
+ const HOSTNAME_RE = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i;
19
+ const MAX_RULES_PER_ZONE = 100;
20
+ /**
21
+ * Validates a single rate limit rule and returns a Cloudflare Ruleset rule object.
22
+ *
23
+ * @param globalIndex - A unique index across all rules in the ruleset, used for
24
+ * resource naming and error messages.
25
+ * @throws if the rule configuration is invalid.
26
+ * @internal Exported for testing.
27
+ */
28
+ function buildRulesetRule(serviceName, hostname, rule, globalIndex) {
29
+ if (!hostname || !HOSTNAME_RE.test(hostname)) {
30
+ throw new Error(`Service "${serviceName}" rate limit rule ${globalIndex}: invalid hostname "${hostname}". ` +
31
+ `Must be a valid domain (e.g. "my-service.decentraland.org").`);
32
+ }
33
+ if (!rule.requestsPerPeriod || rule.requestsPerPeriod < 1) {
34
+ throw new Error(`Service "${serviceName}" rate limit rule ${globalIndex}: requestsPerPeriod must be >= 1, got ${rule.requestsPerPeriod}.`);
35
+ }
36
+ if (rule.action === "block") {
37
+ if (rule.mitigationTimeout == null) {
38
+ throw new Error(`Service "${serviceName}" rate limit rule ${globalIndex}: mitigationTimeout is required when action is "block".`);
39
+ }
40
+ if (rule.mitigationTimeout < rule.period) {
41
+ throw new Error(`Service "${serviceName}" rate limit rule ${globalIndex}: mitigationTimeout (${rule.mitigationTimeout}) must be >= period (${rule.period}).`);
42
+ }
43
+ }
44
+ const hostnameCondition = `(http.host eq "${hostname}")`;
45
+ const expression = rule.expression
46
+ ? `${hostnameCondition} and (${rule.expression})`
47
+ : hostnameCondition;
48
+ return {
49
+ action: ACTION_MAP[rule.action],
50
+ expression,
51
+ description: rule.description || `Rate limit rule ${globalIndex} (auto-created by services-pipeline for ${serviceName})`,
52
+ enabled: true,
53
+ ratelimit: {
54
+ characteristics: ["cf.colo.id", "ip.src"],
55
+ period: rule.period,
56
+ requestsPerPeriod: rule.requestsPerPeriod,
57
+ mitigationTimeout: rule.action === "block" ? rule.mitigationTimeout : 0,
58
+ requestsToOrigin: rule.countCachedRequests === false,
59
+ },
60
+ };
61
+ }
62
+ exports.buildRulesetRule = buildRulesetRule;
63
+ let rulesetCreated = false;
64
+ /** @internal Reset module state for testing. */
65
+ function _resetForTesting() {
66
+ rulesetCreated = false;
67
+ }
68
+ exports._resetForTesting = _resetForTesting;
69
+ /**
70
+ * Creates a single zone-level Cloudflare Ruleset containing rate limiting rules
71
+ * aggregated from all services. This must be called from a centralized Pulumi
72
+ * stack — not from individual service stacks — because Cloudflare only supports
73
+ * one `http_ratelimit` phase ruleset per zone.
74
+ *
75
+ * This function must only be called once per Pulumi program. A second call will
76
+ * throw to prevent silent overwrites of the zone-level ruleset.
77
+ *
78
+ * Rules are ordered so that rules with a custom expression (narrower scope) are
79
+ * evaluated before hostname-only rules (broader scope). Within each group, the
80
+ * original insertion order is preserved.
81
+ *
82
+ * @param services - Array of service rate limiting configurations to aggregate.
83
+ * @returns The created Ruleset, or undefined if no services have rate limiting rules.
84
+ */
85
+ async function createZoneRateLimitRuleset(services) {
86
+ if (services.length === 0)
87
+ return undefined;
88
+ if (rulesetCreated) {
89
+ throw new Error("createZoneRateLimitRuleset has already been called in this program. " +
90
+ "Cloudflare only supports one http_ratelimit phase ruleset per zone.");
91
+ }
92
+ rulesetCreated = true;
93
+ const zoneId = await (0, cloudflare_1.getZoneId)();
94
+ let globalIndex = 0;
95
+ const rules = services.flatMap((svc) => svc.rules.map((rule) => buildRulesetRule(svc.serviceName, svc.hostname, rule, globalIndex++)));
96
+ if (rules.length > MAX_RULES_PER_ZONE) {
97
+ throw new Error(`Total rate limiting rules (${rules.length}) exceeds the maximum of ${MAX_RULES_PER_ZONE} per zone. ` +
98
+ `Reduce the number of rules across services.`);
99
+ }
100
+ // Sort: rules with a custom expression (narrower scope) first, then
101
+ // hostname-only rules (broader scope). Preserves relative order within
102
+ // each group via stable sort.
103
+ const sorted = rules.sort((a, b) => {
104
+ const aHasExpr = a.expression.includes(" and ");
105
+ const bHasExpr = b.expression.includes(" and ");
106
+ if (aHasExpr === bHasExpr)
107
+ return 0;
108
+ return aHasExpr ? -1 : 1;
109
+ });
110
+ return new cf.Ruleset(`zone-rate-limits-${domain_1.publicDomain}`, {
111
+ zoneId,
112
+ name: `Rate limits for ${domain_1.publicDomain}`,
113
+ description: `Auto-created by services-pipeline — aggregated rate limiting rules for all services`,
114
+ kind: "zone",
115
+ phase: "http_ratelimit",
116
+ rules: sorted,
117
+ });
118
+ }
119
+ exports.createZoneRateLimitRuleset = createZoneRateLimitRuleset;
120
+ //# sourceMappingURL=rateLimiting.js.map