@nymbalhq/security-cloudflare 0.0.0-stage → 1.2.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/dist/index.d.ts +119 -0
- package/dist/index.js +547 -0
- package/dist/index.js.map +1 -0
- package/package.json +43 -3
- package/recipes/cloudflare.json +35 -0
- package/README.md +0 -3
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { AdapterError, SecurityAdapter, SecurityConfigLike, DriftReport, SecurityState, Logger, AdapterContext } from '@nymbalhq/types';
|
|
2
|
+
|
|
3
|
+
/** The entrypoint phases Nymbal writes, in the order `apply` writes them. */
|
|
4
|
+
declare const PHASES: readonly ["http_ratelimit", "http_request_firewall_custom", "http_request_firewall_managed", "http_request_cache_settings"];
|
|
5
|
+
type Phase = (typeof PHASES)[number];
|
|
6
|
+
/** A ruleset rule as the Cloudflare v4 API reads and writes it. */
|
|
7
|
+
interface Rule {
|
|
8
|
+
id?: string;
|
|
9
|
+
ref?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
expression: string;
|
|
12
|
+
action: string;
|
|
13
|
+
action_parameters?: Record<string, unknown>;
|
|
14
|
+
ratelimit?: {
|
|
15
|
+
characteristics: string[];
|
|
16
|
+
period: number;
|
|
17
|
+
requests_per_period: number;
|
|
18
|
+
mitigation_timeout: number;
|
|
19
|
+
};
|
|
20
|
+
enabled?: boolean;
|
|
21
|
+
[key: string]: unknown;
|
|
22
|
+
}
|
|
23
|
+
declare const isNymbalRule: (rule: {
|
|
24
|
+
ref?: unknown;
|
|
25
|
+
}) => boolean;
|
|
26
|
+
/** Each `rateLimit` window Cloudflare has a period for, in seconds. */
|
|
27
|
+
declare const WINDOW_PERIODS: Readonly<Record<string, number>>;
|
|
28
|
+
/** Cloudflare's own managed rulesets, by the name `managedRulesets` uses. */
|
|
29
|
+
declare const MANAGED_RULESET_IDS: {
|
|
30
|
+
readonly cloudflare: "efb7b8c949ac4650a09736fc376e9aee";
|
|
31
|
+
readonly owasp: "4814384a9e5d4991b9815dcfc25d2f1f";
|
|
32
|
+
};
|
|
33
|
+
interface RateLimitEntry {
|
|
34
|
+
requests: number;
|
|
35
|
+
window: string;
|
|
36
|
+
action: 'block' | 'challenge' | 'throttle';
|
|
37
|
+
}
|
|
38
|
+
interface CustomRule {
|
|
39
|
+
name: string;
|
|
40
|
+
expression: string;
|
|
41
|
+
action: 'block' | 'managed_challenge' | 'js_challenge' | 'log';
|
|
42
|
+
}
|
|
43
|
+
/** The parts of the `security` block the edge rules come from; every one may be absent. */
|
|
44
|
+
interface EdgeRulesConfig {
|
|
45
|
+
rateLimit?: Record<string, RateLimitEntry>;
|
|
46
|
+
geo?: {
|
|
47
|
+
block?: string[];
|
|
48
|
+
};
|
|
49
|
+
customRules?: CustomRule[];
|
|
50
|
+
managedRulesets?: string[];
|
|
51
|
+
}
|
|
52
|
+
/** A key with a `*` that is not part of `**`: one segment at the origin, any number at the edge. */
|
|
53
|
+
declare const hasSingleStar: (key: string) => boolean;
|
|
54
|
+
/**
|
|
55
|
+
* The rules the config puts on the zone, by phase, every one carrying a `nymbal:` ref. Rate limits follow their keys'
|
|
56
|
+
* sorted order and custom rules their config order, so the same config always gives the same rules. A key that is not
|
|
57
|
+
* a path, or a window with no Cloudflare period, gives no rule: `validate` reports both.
|
|
58
|
+
*/
|
|
59
|
+
declare function desiredRules(config: EdgeRulesConfig): Record<Phase, Rule[]>;
|
|
60
|
+
|
|
61
|
+
declare const DEFAULT_API_BASE = "https://api.cloudflare.com/client/v4";
|
|
62
|
+
declare const REQUEST_TIMEOUT_MS = 5000;
|
|
63
|
+
/** The `security.cloudflare` block: the adapter's `ctx.config`. */
|
|
64
|
+
interface CloudflareConnection {
|
|
65
|
+
zoneId?: string;
|
|
66
|
+
apiToken?: string;
|
|
67
|
+
apiBase?: string;
|
|
68
|
+
}
|
|
69
|
+
/** The token or zone was refused (401 or 403), or the config has none. */
|
|
70
|
+
declare class CloudflareCredentialsError extends AdapterError {
|
|
71
|
+
constructor(detail: string, context?: Record<string, unknown>);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type Plan = 'free' | 'pro' | 'business' | 'enterprise';
|
|
75
|
+
/** What each plan allows; every limit counts every rule in the phase, the zone owner's included. */
|
|
76
|
+
declare const PLAN_LIMITS: Readonly<Record<Plan, {
|
|
77
|
+
rateLimitRules: number;
|
|
78
|
+
periods: readonly number[];
|
|
79
|
+
customRules: number;
|
|
80
|
+
managedRulesets: boolean;
|
|
81
|
+
cacheRules: number;
|
|
82
|
+
}>>;
|
|
83
|
+
interface PhasePlan {
|
|
84
|
+
phase: Phase;
|
|
85
|
+
add: Rule[];
|
|
86
|
+
/** `position` is set when the rule is out of place: its index on the zone, and the one `apply` writes it at. */
|
|
87
|
+
change: Array<{
|
|
88
|
+
ref: string;
|
|
89
|
+
from: Rule;
|
|
90
|
+
to: Rule;
|
|
91
|
+
position?: {
|
|
92
|
+
from: number;
|
|
93
|
+
to: number;
|
|
94
|
+
};
|
|
95
|
+
}>;
|
|
96
|
+
remove: Rule[];
|
|
97
|
+
}
|
|
98
|
+
/** What `apply` would change (from `plan`) or changed (from `apply`), phase by phase. */
|
|
99
|
+
interface SecurityPlan {
|
|
100
|
+
phases: PhasePlan[];
|
|
101
|
+
}
|
|
102
|
+
declare const isEmptyPlan: (plan: SecurityPlan) => boolean;
|
|
103
|
+
interface CloudflareSecurityAdapter extends SecurityAdapter {
|
|
104
|
+
drift(config: SecurityConfigLike): Promise<DriftReport>;
|
|
105
|
+
inspect(): Promise<SecurityState>;
|
|
106
|
+
plan(config: SecurityConfigLike): Promise<SecurityPlan>;
|
|
107
|
+
/** Writes each phase whose Nymbal rules differ, one by one; it rejects at the first refused write. */
|
|
108
|
+
apply(config: SecurityConfigLike): Promise<SecurityPlan>;
|
|
109
|
+
}
|
|
110
|
+
declare function createCloudflareSecurityAdapter(deps: {
|
|
111
|
+
connection: CloudflareConnection;
|
|
112
|
+
logger: Logger;
|
|
113
|
+
}): CloudflareSecurityAdapter;
|
|
114
|
+
|
|
115
|
+
/** The kernel's entry: `security: { adapter: 'cloudflare', cloudflare: CloudflareConnection }`. */
|
|
116
|
+
declare function createAdapter(ctx: AdapterContext): CloudflareSecurityAdapter;
|
|
117
|
+
|
|
118
|
+
export { CloudflareCredentialsError, DEFAULT_API_BASE, MANAGED_RULESET_IDS, PHASES, PLAN_LIMITS, REQUEST_TIMEOUT_MS, WINDOW_PERIODS, createAdapter, createCloudflareSecurityAdapter, desiredRules, hasSingleStar, isEmptyPlan, isNymbalRule };
|
|
119
|
+
export type { CloudflareConnection, CloudflareSecurityAdapter, EdgeRulesConfig, Phase, PhasePlan, Plan, Rule, SecurityPlan };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { ConfigError } from "@nymbalhq/types";
|
|
3
|
+
|
|
4
|
+
// src/adapter.ts
|
|
5
|
+
import {
|
|
6
|
+
AdapterError as AdapterError2,
|
|
7
|
+
boundedHealthCheck,
|
|
8
|
+
buildSecurityHeaders,
|
|
9
|
+
errorText as errorText2
|
|
10
|
+
} from "@nymbalhq/types";
|
|
11
|
+
|
|
12
|
+
// src/client.ts
|
|
13
|
+
import { AdapterError, errorText } from "@nymbalhq/types";
|
|
14
|
+
var DEFAULT_API_BASE = "https://api.cloudflare.com/client/v4";
|
|
15
|
+
var REQUEST_TIMEOUT_MS = 5e3;
|
|
16
|
+
var CREDENTIALS_HINT = "check security.cloudflare.zoneId and apiToken (CLOUDFLARE_ZONE_ID, CLOUDFLARE_API_TOKEN): the token needs Zone \u203A Zone \u203A Read, Zone \u203A Zone WAF \u203A Edit and Zone \u203A Cache Rules \u203A Edit on the zone";
|
|
17
|
+
var CloudflareCredentialsError = class extends AdapterError {
|
|
18
|
+
constructor(detail, context = {}) {
|
|
19
|
+
super("cloudflare_credentials", `Cloudflare: ${detail}; ${CREDENTIALS_HINT}`, { context });
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
function hasCredentials(connection) {
|
|
23
|
+
return Boolean(connection.zoneId) && Boolean(connection.apiToken);
|
|
24
|
+
}
|
|
25
|
+
function apiErrors(body) {
|
|
26
|
+
const errors = body?.errors ?? [];
|
|
27
|
+
const messages = errors.map((e) => e.message).filter((m) => typeof m === "string");
|
|
28
|
+
return messages.length > 0 ? messages.join("; ") : "no error message";
|
|
29
|
+
}
|
|
30
|
+
function createClient(connection) {
|
|
31
|
+
const base = (connection.apiBase ?? DEFAULT_API_BASE).replace(/\/+$/, "");
|
|
32
|
+
const zonePath = `/zones/${encodeURIComponent(connection.zoneId ?? "")}`;
|
|
33
|
+
async function request(method, path, body) {
|
|
34
|
+
if (!hasCredentials(connection)) {
|
|
35
|
+
throw new CloudflareCredentialsError("no zone ID or API token is configured");
|
|
36
|
+
}
|
|
37
|
+
let response;
|
|
38
|
+
let text;
|
|
39
|
+
try {
|
|
40
|
+
response = await fetch(`${base}${path}`, {
|
|
41
|
+
method,
|
|
42
|
+
headers: {
|
|
43
|
+
authorization: `Bearer ${connection.apiToken}`,
|
|
44
|
+
...body !== void 0 && { "content-type": "application/json" }
|
|
45
|
+
},
|
|
46
|
+
...body !== void 0 && { body: JSON.stringify(body) },
|
|
47
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
48
|
+
});
|
|
49
|
+
text = await response.text();
|
|
50
|
+
} catch (error) {
|
|
51
|
+
throw new AdapterError(
|
|
52
|
+
"cloudflare_unreachable",
|
|
53
|
+
`Cloudflare API at ${base} did not answer ${method} ${path}: ${errorText(error)}`,
|
|
54
|
+
{ cause: error, context: { method, path } }
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = text === "" ? void 0 : JSON.parse(text);
|
|
60
|
+
} catch {
|
|
61
|
+
parsed = void 0;
|
|
62
|
+
}
|
|
63
|
+
if (response.status === 401 || response.status === 403) {
|
|
64
|
+
throw new CloudflareCredentialsError(
|
|
65
|
+
`${method} ${path} was refused (${response.status}: ${apiErrors(parsed)})`,
|
|
66
|
+
{ status: response.status }
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (response.ok && (typeof parsed !== "object" || parsed === null)) {
|
|
70
|
+
throw new AdapterError(
|
|
71
|
+
"cloudflare_api",
|
|
72
|
+
`Cloudflare answered ${method} ${path} with ${response.status} and no JSON body`,
|
|
73
|
+
{ context: { method, path, status: response.status } }
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return { status: response.status, body: parsed };
|
|
77
|
+
}
|
|
78
|
+
const failed = (method, path, status, body) => new AdapterError(
|
|
79
|
+
"cloudflare_api",
|
|
80
|
+
`Cloudflare answered ${method} ${path} with ${status}: ${apiErrors(body)}`,
|
|
81
|
+
{ context: { method, path, status } }
|
|
82
|
+
);
|
|
83
|
+
return {
|
|
84
|
+
async zone() {
|
|
85
|
+
const { status, body } = await request("GET", zonePath);
|
|
86
|
+
if (status === 404) {
|
|
87
|
+
throw new CloudflareCredentialsError(`zone ${connection.zoneId} was not found`, { status });
|
|
88
|
+
}
|
|
89
|
+
if (status !== 200) throw failed("GET", zonePath, status, body);
|
|
90
|
+
return body.result;
|
|
91
|
+
},
|
|
92
|
+
async entrypoint(phase) {
|
|
93
|
+
const path = `${zonePath}/rulesets/phases/${phase}/entrypoint`;
|
|
94
|
+
const { status, body } = await request("GET", path);
|
|
95
|
+
if (status === 404) return void 0;
|
|
96
|
+
if (status !== 200) throw failed("GET", path, status, body);
|
|
97
|
+
return body.result?.rules ?? [];
|
|
98
|
+
},
|
|
99
|
+
async putEntrypoint(phase, rules) {
|
|
100
|
+
const path = `${zonePath}/rulesets/phases/${phase}/entrypoint`;
|
|
101
|
+
const { status, body } = await request("PUT", path, { rules });
|
|
102
|
+
if (status !== 200) throw failed("PUT", path, status, body);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/rules.ts
|
|
108
|
+
var PHASES = [
|
|
109
|
+
"http_ratelimit",
|
|
110
|
+
"http_request_firewall_custom",
|
|
111
|
+
"http_request_firewall_managed",
|
|
112
|
+
"http_request_cache_settings"
|
|
113
|
+
];
|
|
114
|
+
var NYMBAL_REF_PREFIX = "nymbal:";
|
|
115
|
+
var isNymbalRule = (rule) => typeof rule.ref === "string" && rule.ref.startsWith(NYMBAL_REF_PREFIX);
|
|
116
|
+
var WINDOW_PERIODS = {
|
|
117
|
+
"10s": 10,
|
|
118
|
+
"1m": 60,
|
|
119
|
+
"2m": 120,
|
|
120
|
+
"5m": 300,
|
|
121
|
+
"10m": 600,
|
|
122
|
+
"1h": 3600
|
|
123
|
+
};
|
|
124
|
+
var MANAGED_RULESET_IDS = {
|
|
125
|
+
cloudflare: "efb7b8c949ac4650a09736fc376e9aee",
|
|
126
|
+
owasp: "4814384a9e5d4991b9815dcfc25d2f1f"
|
|
127
|
+
};
|
|
128
|
+
var MANAGED_NAMES = {
|
|
129
|
+
cloudflare: "Cloudflare Managed Ruleset",
|
|
130
|
+
owasp: "Cloudflare OWASP Core Ruleset"
|
|
131
|
+
};
|
|
132
|
+
var quoted = (text) => `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
133
|
+
function pathExpression(key) {
|
|
134
|
+
return key.includes("*") ? `http.request.uri.path wildcard ${quoted(key.replace(/\*+/g, "*"))}` : `http.request.uri.path eq ${quoted(key)}`;
|
|
135
|
+
}
|
|
136
|
+
var hasSingleStar = (key) => /(^|[^*])\*([^*]|$)/.test(key);
|
|
137
|
+
var isEdgePath = (key) => key.startsWith("/");
|
|
138
|
+
function rateLimitRule(key, entry) {
|
|
139
|
+
const period = WINDOW_PERIODS[entry.window];
|
|
140
|
+
if (!isEdgePath(key) || period === void 0) return void 0;
|
|
141
|
+
const [action, mitigationTimeout] = entry.action === "challenge" ? ["managed_challenge", 0] : entry.action === "throttle" ? ["block", 0] : ["block", period];
|
|
142
|
+
return {
|
|
143
|
+
ref: `${NYMBAL_REF_PREFIX}ratelimit:${key}`,
|
|
144
|
+
description: `${NYMBAL_REF_PREFIX} rate limit ${key}`,
|
|
145
|
+
expression: pathExpression(key),
|
|
146
|
+
action,
|
|
147
|
+
ratelimit: {
|
|
148
|
+
characteristics: ["cf.colo.id", "ip.src"],
|
|
149
|
+
period,
|
|
150
|
+
requests_per_period: entry.requests,
|
|
151
|
+
mitigation_timeout: mitigationTimeout
|
|
152
|
+
},
|
|
153
|
+
enabled: true
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
var fragmentCacheRule = () => ({
|
|
157
|
+
ref: `${NYMBAL_REF_PREFIX}cache:fragments`,
|
|
158
|
+
description: `${NYMBAL_REF_PREFIX} cache the fragment routes`,
|
|
159
|
+
expression: 'starts_with(http.request.uri.path, "/fragments/")',
|
|
160
|
+
action: "set_cache_settings",
|
|
161
|
+
action_parameters: {
|
|
162
|
+
cache: true,
|
|
163
|
+
edge_ttl: { mode: "respect_origin" },
|
|
164
|
+
browser_ttl: { mode: "respect_origin" }
|
|
165
|
+
},
|
|
166
|
+
enabled: true
|
|
167
|
+
});
|
|
168
|
+
function desiredRules(config) {
|
|
169
|
+
const rateLimits = Object.entries(config.rateLimit ?? {}).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).flatMap(([key, entry]) => rateLimitRule(key, entry) ?? []);
|
|
170
|
+
const custom = [];
|
|
171
|
+
const blocked = config.geo?.block ?? [];
|
|
172
|
+
if (blocked.length > 0) {
|
|
173
|
+
custom.push({
|
|
174
|
+
ref: `${NYMBAL_REF_PREFIX}geo:block`,
|
|
175
|
+
description: `${NYMBAL_REF_PREFIX} geo block ${blocked.join(" ")}`,
|
|
176
|
+
expression: `ip.src.country in {${blocked.map(quoted).join(" ")}}`,
|
|
177
|
+
action: "block",
|
|
178
|
+
enabled: true
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
for (const rule of config.customRules ?? []) {
|
|
182
|
+
custom.push({
|
|
183
|
+
ref: `${NYMBAL_REF_PREFIX}custom:${rule.name}`,
|
|
184
|
+
description: `${NYMBAL_REF_PREFIX} ${rule.name}`,
|
|
185
|
+
expression: rule.expression,
|
|
186
|
+
action: rule.action,
|
|
187
|
+
enabled: true
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
const managed = [...new Set(config.managedRulesets ?? [])].filter((name) => Object.hasOwn(MANAGED_RULESET_IDS, name)).map(
|
|
191
|
+
(name) => ({
|
|
192
|
+
ref: `${NYMBAL_REF_PREFIX}managed:${name}`,
|
|
193
|
+
description: `${NYMBAL_REF_PREFIX} ${MANAGED_NAMES[name]}`,
|
|
194
|
+
expression: "true",
|
|
195
|
+
action: "execute",
|
|
196
|
+
action_parameters: { id: MANAGED_RULESET_IDS[name] },
|
|
197
|
+
enabled: true
|
|
198
|
+
})
|
|
199
|
+
);
|
|
200
|
+
return {
|
|
201
|
+
http_ratelimit: rateLimits,
|
|
202
|
+
http_request_firewall_custom: custom,
|
|
203
|
+
http_request_firewall_managed: managed,
|
|
204
|
+
http_request_cache_settings: [fragmentCacheRule()]
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// src/adapter.ts
|
|
209
|
+
var PLAN_LIMITS = {
|
|
210
|
+
free: {
|
|
211
|
+
rateLimitRules: 1,
|
|
212
|
+
periods: [10],
|
|
213
|
+
customRules: 5,
|
|
214
|
+
managedRulesets: false,
|
|
215
|
+
cacheRules: 10
|
|
216
|
+
},
|
|
217
|
+
pro: {
|
|
218
|
+
rateLimitRules: 2,
|
|
219
|
+
periods: [10, 60],
|
|
220
|
+
customRules: 20,
|
|
221
|
+
managedRulesets: true,
|
|
222
|
+
cacheRules: 25
|
|
223
|
+
},
|
|
224
|
+
business: {
|
|
225
|
+
rateLimitRules: 5,
|
|
226
|
+
periods: [10, 60, 120, 300, 600],
|
|
227
|
+
customRules: 100,
|
|
228
|
+
managedRulesets: true,
|
|
229
|
+
cacheRules: 50
|
|
230
|
+
},
|
|
231
|
+
enterprise: {
|
|
232
|
+
rateLimitRules: 100,
|
|
233
|
+
periods: [10, 60, 120, 300, 600, 3600],
|
|
234
|
+
customRules: 1e3,
|
|
235
|
+
managedRulesets: true,
|
|
236
|
+
cacheRules: 300
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
var isPlan = (value) => typeof value === "string" && Object.hasOwn(PLAN_LIMITS, value);
|
|
240
|
+
var isEmptyPlan = (plan) => plan.phases.every((p) => p.add.length + p.change.length + p.remove.length === 0);
|
|
241
|
+
function holds(desired, live) {
|
|
242
|
+
if (Array.isArray(desired)) {
|
|
243
|
+
return Array.isArray(live) && live.length === desired.length && desired.every((d, i) => holds(d, live[i]));
|
|
244
|
+
}
|
|
245
|
+
if (typeof desired === "object" && desired !== null) {
|
|
246
|
+
if (typeof live !== "object" || live === null) return false;
|
|
247
|
+
return Object.entries(desired).every(([k, v]) => holds(v, live[k]));
|
|
248
|
+
}
|
|
249
|
+
return desired === live;
|
|
250
|
+
}
|
|
251
|
+
var changedFields = (desired, live) => Object.keys(desired).filter((field) => !holds(desired[field], live[field]));
|
|
252
|
+
function byRef(rules) {
|
|
253
|
+
return new Map(rules.filter(isNymbalRule).map((r) => [r.ref, r]));
|
|
254
|
+
}
|
|
255
|
+
var keyOf = (rule) => isNymbalRule(rule) ? `ref:${String(rule.ref)}` : `id:${String(rule.id)}`;
|
|
256
|
+
function misplaced(live, desired) {
|
|
257
|
+
const wanted = byRef(desired);
|
|
258
|
+
const kept = live.filter((r) => !isNymbalRule(r) || wanted.has(r.ref));
|
|
259
|
+
const ours = kept.flatMap((r, i) => isNymbalRule(r) ? [i] : []);
|
|
260
|
+
const liveRefs = ours.map((i) => kept[i].ref);
|
|
261
|
+
const present = new Set(liveRefs);
|
|
262
|
+
const inOrder = desired.filter((r) => present.has(r.ref)).map((r) => r.ref);
|
|
263
|
+
const oneRun = ours.length === 0 || ours[ours.length - 1] - ours[0] === ours.length - 1;
|
|
264
|
+
const out = /* @__PURE__ */ new Map();
|
|
265
|
+
if (oneRun && liveRefs.every((ref, i) => ref === inOrder[i])) return out;
|
|
266
|
+
const written = nextRules(kept, desired).filter(
|
|
267
|
+
(r) => !isNymbalRule(r) || present.has(r.ref)
|
|
268
|
+
);
|
|
269
|
+
const target = new Map(written.map((r, i) => [keyOf(r), i]));
|
|
270
|
+
for (const i of ours) {
|
|
271
|
+
const ref = kept[i].ref;
|
|
272
|
+
const to = target.get(keyOf(kept[i]));
|
|
273
|
+
if (to !== i) out.set(ref, { from: i, to });
|
|
274
|
+
}
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
function diffPhase(phase, live, desired) {
|
|
278
|
+
const current = byRef(live);
|
|
279
|
+
const wanted = byRef(desired);
|
|
280
|
+
const moved = misplaced(live, desired);
|
|
281
|
+
const plan = { phase, add: [], change: [], remove: [] };
|
|
282
|
+
for (const [ref, to] of wanted) {
|
|
283
|
+
const from = current.get(ref);
|
|
284
|
+
const position = moved.get(ref);
|
|
285
|
+
if (from === void 0) plan.add.push(to);
|
|
286
|
+
else if (changedFields(to, from).length > 0 || position !== void 0) {
|
|
287
|
+
plan.change.push({ ref, from, to, ...position !== void 0 && { position } });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
for (const [ref, rule] of current) if (!wanted.has(ref)) plan.remove.push(rule);
|
|
291
|
+
return plan;
|
|
292
|
+
}
|
|
293
|
+
function nextRules(live, desired) {
|
|
294
|
+
const ids = new Map(live.filter(isNymbalRule).map((r) => [r.ref, r.id]));
|
|
295
|
+
const own = live.filter((r) => !isNymbalRule(r)).map(({ version: _version, last_updated: _updated, ...rule }) => rule);
|
|
296
|
+
const ours = desired.map((rule) => {
|
|
297
|
+
const id = ids.get(rule.ref);
|
|
298
|
+
return id === void 0 ? rule : { ...rule, id };
|
|
299
|
+
});
|
|
300
|
+
return [...own, ...ours];
|
|
301
|
+
}
|
|
302
|
+
function createCloudflareSecurityAdapter(deps) {
|
|
303
|
+
const { connection, logger } = deps;
|
|
304
|
+
const client = createClient(connection);
|
|
305
|
+
let lastValidatedAt = "";
|
|
306
|
+
const livePhases = async () => {
|
|
307
|
+
const read = await Promise.all(PHASES.map((phase) => client.entrypoint(phase)));
|
|
308
|
+
return Object.fromEntries(PHASES.map((phase, i) => [phase, read[i] ?? []]));
|
|
309
|
+
};
|
|
310
|
+
const desiredOf = (config) => desiredRules(config);
|
|
311
|
+
function configErrors(config) {
|
|
312
|
+
const errors = [];
|
|
313
|
+
for (const [key, entry] of Object.entries(config.rateLimit ?? {})) {
|
|
314
|
+
if (!isEdgePath(key)) {
|
|
315
|
+
errors.push({
|
|
316
|
+
code: "cloudflare.path",
|
|
317
|
+
path: `rateLimit.${key}`,
|
|
318
|
+
message: `rate limit key '${key}' is not a path (it must start with /), so it has no edge rule`
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
if (WINDOW_PERIODS[entry.window] === void 0) {
|
|
322
|
+
errors.push({
|
|
323
|
+
code: "cloudflare.period",
|
|
324
|
+
path: `rateLimit.${key}.window`,
|
|
325
|
+
message: `window '${entry.window}' has no Cloudflare period; use one of ${Object.keys(WINDOW_PERIODS).join(", ")}`
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
for (const code of config.geo?.block ?? []) {
|
|
330
|
+
if (!/^[A-Z]{2}$/.test(code)) {
|
|
331
|
+
errors.push({
|
|
332
|
+
code: "cloudflare.geo",
|
|
333
|
+
path: "geo.block",
|
|
334
|
+
message: `'${code}' is not an ISO 3166 alpha-2 country code (two capital letters)`
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return errors;
|
|
339
|
+
}
|
|
340
|
+
function planErrors(plan2, config, live) {
|
|
341
|
+
const limits = PLAN_LIMITS[plan2];
|
|
342
|
+
const errors = [];
|
|
343
|
+
for (const [key, entry] of Object.entries(config.rateLimit ?? {})) {
|
|
344
|
+
const period = WINDOW_PERIODS[entry.window];
|
|
345
|
+
if (period !== void 0 && !limits.periods.includes(period)) {
|
|
346
|
+
errors.push({
|
|
347
|
+
code: "cloudflare.period",
|
|
348
|
+
path: `rateLimit.${key}.window`,
|
|
349
|
+
message: `window '${entry.window}' is not allowed on the ${plan2} plan; it allows periods of ${limits.periods.join(", ")} s`
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
if (entry.action === "throttle" && plan2 !== "enterprise") {
|
|
353
|
+
errors.push({
|
|
354
|
+
code: "cloudflare.action",
|
|
355
|
+
path: `rateLimit.${key}.action`,
|
|
356
|
+
message: `throttle needs the enterprise plan; the zone is on the ${plan2} plan (use block or challenge)`
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const desired = desiredRules(config);
|
|
361
|
+
const counted = [
|
|
362
|
+
["http_ratelimit", limits.rateLimitRules, "rate limiting rules"],
|
|
363
|
+
["http_request_firewall_custom", limits.customRules, "custom rules"],
|
|
364
|
+
["http_request_cache_settings", limits.cacheRules, "cache rules"]
|
|
365
|
+
];
|
|
366
|
+
for (const [phase, limit, what] of counted) {
|
|
367
|
+
const own = live[phase].filter((r) => !isNymbalRule(r)).length;
|
|
368
|
+
const total = own + desired[phase].length;
|
|
369
|
+
if (total > limit) {
|
|
370
|
+
errors.push({
|
|
371
|
+
code: "cloudflare.plan-limit",
|
|
372
|
+
path: phase,
|
|
373
|
+
message: `${phase} would hold ${total} ${what} (${own} of the zone's own, ${desired[phase].length} of Nymbal's); the ${plan2} plan allows ${limit}`
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (!limits.managedRulesets && desired.http_request_firewall_managed.length > 0) {
|
|
378
|
+
errors.push({
|
|
379
|
+
code: "cloudflare.plan-limit",
|
|
380
|
+
path: "managedRulesets",
|
|
381
|
+
message: `managed rulesets need the pro plan or above; the zone is on the ${plan2} plan`
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
return errors;
|
|
385
|
+
}
|
|
386
|
+
async function plan(config) {
|
|
387
|
+
const live = await livePhases();
|
|
388
|
+
const desired = desiredOf(config);
|
|
389
|
+
return { phases: PHASES.map((phase) => diffPhase(phase, live[phase], desired[phase])) };
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
capabilities: () => ["headers", "edge-rules"],
|
|
393
|
+
producesEvents: [],
|
|
394
|
+
consumesEvents: [],
|
|
395
|
+
// Boot makes no request: the zone changes only by `nymbal security:apply`.
|
|
396
|
+
initialize: () => Promise.resolve(),
|
|
397
|
+
healthCheck() {
|
|
398
|
+
return boundedHealthCheck(async () => {
|
|
399
|
+
const zone = await client.zone();
|
|
400
|
+
const plan2 = zone.plan?.legacy_id ?? "unknown";
|
|
401
|
+
return { status: "healthy", detail: `zone ${zone.name ?? zone.id} on the ${plan2} plan` };
|
|
402
|
+
});
|
|
403
|
+
},
|
|
404
|
+
async applyMiddleware(app, config) {
|
|
405
|
+
app.setDefaultHeaders(buildSecurityHeaders(config));
|
|
406
|
+
logger.debug({}, "security headers attached");
|
|
407
|
+
},
|
|
408
|
+
async validate(config) {
|
|
409
|
+
const edge = config;
|
|
410
|
+
if (!hasCredentials(connection)) {
|
|
411
|
+
const missing = [
|
|
412
|
+
...connection.zoneId ? [] : ["zoneId"],
|
|
413
|
+
...connection.apiToken ? [] : ["apiToken"]
|
|
414
|
+
];
|
|
415
|
+
return {
|
|
416
|
+
valid: false,
|
|
417
|
+
errors: [
|
|
418
|
+
{
|
|
419
|
+
code: "cloudflare.credentials",
|
|
420
|
+
path: "cloudflare",
|
|
421
|
+
message: `security.cloudflare has no ${missing.join(" or ")} (CLOUDFLARE_ZONE_ID, CLOUDFLARE_API_TOKEN)`
|
|
422
|
+
}
|
|
423
|
+
]
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
const errors = configErrors(edge);
|
|
427
|
+
try {
|
|
428
|
+
const zone = await client.zone();
|
|
429
|
+
const plan2 = zone.plan?.legacy_id;
|
|
430
|
+
if (!isPlan(plan2)) {
|
|
431
|
+
errors.push({
|
|
432
|
+
code: "cloudflare.plan-limit",
|
|
433
|
+
message: `the zone's plan '${String(plan2)}' is not one Nymbal knows the limits of (free, pro, business, enterprise)`
|
|
434
|
+
});
|
|
435
|
+
} else {
|
|
436
|
+
errors.push(...planErrors(plan2, edge, await livePhases()));
|
|
437
|
+
}
|
|
438
|
+
} catch (error) {
|
|
439
|
+
if (!(error instanceof CloudflareCredentialsError)) throw error;
|
|
440
|
+
errors.push({ code: "cloudflare.credentials", message: error.message });
|
|
441
|
+
}
|
|
442
|
+
lastValidatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
443
|
+
return { valid: errors.length === 0, errors };
|
|
444
|
+
},
|
|
445
|
+
plan,
|
|
446
|
+
async apply(config) {
|
|
447
|
+
const desired = desiredOf(config);
|
|
448
|
+
const written = [];
|
|
449
|
+
for (const phase of PHASES) {
|
|
450
|
+
const live = await client.entrypoint(phase);
|
|
451
|
+
const change = diffPhase(phase, live ?? [], desired[phase]);
|
|
452
|
+
if (change.add.length + change.change.length + change.remove.length === 0) continue;
|
|
453
|
+
try {
|
|
454
|
+
await client.putEntrypoint(phase, nextRules(live ?? [], desired[phase]));
|
|
455
|
+
} catch (error) {
|
|
456
|
+
const done = written.map((p) => p.phase);
|
|
457
|
+
throw new AdapterError2(
|
|
458
|
+
"cloudflare_apply",
|
|
459
|
+
`writing ${phase} failed after ${done.length === 0 ? "no phase" : done.join(", ")} was written: ${errorText2(error)}`,
|
|
460
|
+
{ cause: error, context: { failed: phase, written: done } }
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
logger.info(
|
|
464
|
+
{
|
|
465
|
+
phase,
|
|
466
|
+
added: change.add.length,
|
|
467
|
+
changed: change.change.length,
|
|
468
|
+
removed: change.remove.length
|
|
469
|
+
},
|
|
470
|
+
`wrote ${phase}`
|
|
471
|
+
);
|
|
472
|
+
written.push(change);
|
|
473
|
+
}
|
|
474
|
+
return { phases: written };
|
|
475
|
+
},
|
|
476
|
+
async drift(config) {
|
|
477
|
+
const live = await livePhases();
|
|
478
|
+
const desired = desiredOf(config);
|
|
479
|
+
const differences = [];
|
|
480
|
+
for (const phase of PHASES) {
|
|
481
|
+
const { add, change, remove } = diffPhase(phase, live[phase], desired[phase]);
|
|
482
|
+
for (const rule of add) {
|
|
483
|
+
differences.push({ path: `${phase}/${rule.ref}`, configured: rule, actual: void 0 });
|
|
484
|
+
}
|
|
485
|
+
for (const { ref, from, to, position } of change) {
|
|
486
|
+
for (const field of changedFields(to, from)) {
|
|
487
|
+
differences.push({
|
|
488
|
+
path: `${phase}/${ref}/${field}`,
|
|
489
|
+
configured: to[field],
|
|
490
|
+
actual: from[field]
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
if (position !== void 0) {
|
|
494
|
+
differences.push({
|
|
495
|
+
path: `${phase}/${ref}/position`,
|
|
496
|
+
configured: position.to,
|
|
497
|
+
actual: position.from
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
for (const rule of remove) {
|
|
502
|
+
differences.push({ path: `${phase}/${rule.ref}`, configured: void 0, actual: rule });
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return { drift: differences.length > 0, differences };
|
|
506
|
+
},
|
|
507
|
+
async inspect() {
|
|
508
|
+
const live = await livePhases();
|
|
509
|
+
return {
|
|
510
|
+
rulesApplied: PHASES.flatMap(
|
|
511
|
+
(phase) => live[phase].filter(isNymbalRule).map((r) => r.ref)
|
|
512
|
+
),
|
|
513
|
+
lastValidatedAt
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// src/index.ts
|
|
520
|
+
function createAdapter(ctx) {
|
|
521
|
+
if (ctx.slot !== "security") {
|
|
522
|
+
throw new ConfigError(`@nymbalhq/security-cloudflare serves security, not ${ctx.slot}`, {
|
|
523
|
+
code: "adapter.wrong_slot",
|
|
524
|
+
context: { slot: ctx.slot }
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
return createCloudflareSecurityAdapter({
|
|
528
|
+
connection: ctx.config,
|
|
529
|
+
logger: ctx.logger
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
export {
|
|
533
|
+
CloudflareCredentialsError,
|
|
534
|
+
DEFAULT_API_BASE,
|
|
535
|
+
MANAGED_RULESET_IDS,
|
|
536
|
+
PHASES,
|
|
537
|
+
PLAN_LIMITS,
|
|
538
|
+
REQUEST_TIMEOUT_MS,
|
|
539
|
+
WINDOW_PERIODS,
|
|
540
|
+
createAdapter,
|
|
541
|
+
createCloudflareSecurityAdapter,
|
|
542
|
+
desiredRules,
|
|
543
|
+
hasSingleStar,
|
|
544
|
+
isEmptyPlan,
|
|
545
|
+
isNymbalRule
|
|
546
|
+
};
|
|
547
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/adapter.ts","../src/client.ts","../src/rules.ts"],"sourcesContent":["import { ConfigError, type AdapterContext } from '@nymbalhq/types'\nimport { createCloudflareSecurityAdapter, type CloudflareSecurityAdapter } from './adapter.js'\nimport type { CloudflareConnection } from './client.js'\n\nexport {\n createCloudflareSecurityAdapter,\n isEmptyPlan,\n PLAN_LIMITS,\n type CloudflareSecurityAdapter,\n type PhasePlan,\n type Plan,\n type SecurityPlan,\n} from './adapter.js'\nexport {\n CloudflareCredentialsError,\n DEFAULT_API_BASE,\n REQUEST_TIMEOUT_MS,\n type CloudflareConnection,\n} from './client.js'\nexport {\n desiredRules,\n hasSingleStar,\n isNymbalRule,\n MANAGED_RULESET_IDS,\n PHASES,\n WINDOW_PERIODS,\n type EdgeRulesConfig,\n type Phase,\n type Rule,\n} from './rules.js'\n\n/** The kernel's entry: `security: { adapter: 'cloudflare', cloudflare: CloudflareConnection }`. */\nexport function createAdapter(ctx: AdapterContext): CloudflareSecurityAdapter {\n if (ctx.slot !== 'security') {\n throw new ConfigError(`@nymbalhq/security-cloudflare serves security, not ${ctx.slot}`, {\n code: 'adapter.wrong_slot',\n context: { slot: ctx.slot },\n })\n }\n return createCloudflareSecurityAdapter({\n connection: ctx.config as CloudflareConnection,\n logger: ctx.logger,\n })\n}\n","import {\n AdapterError,\n boundedHealthCheck,\n buildSecurityHeaders,\n errorText,\n type DriftReport,\n type HttpAdapter,\n type Logger,\n type SecurityAdapter,\n type SecurityConfigLike,\n type SecurityState,\n type ValidationResult,\n} from '@nymbalhq/types'\nimport {\n CloudflareCredentialsError,\n createClient,\n hasCredentials,\n type CloudflareClient,\n type CloudflareConnection,\n} from './client.js'\nimport {\n PHASES,\n WINDOW_PERIODS,\n desiredRules,\n isEdgePath,\n isNymbalRule,\n type EdgeRulesConfig,\n type Phase,\n type Rule,\n} from './rules.js'\n\nexport type Plan = 'free' | 'pro' | 'business' | 'enterprise'\n\n/** What each plan allows; every limit counts every rule in the phase, the zone owner's included. */\nexport const PLAN_LIMITS: Readonly<\n Record<\n Plan,\n {\n rateLimitRules: number\n periods: readonly number[]\n customRules: number\n managedRulesets: boolean\n cacheRules: number\n }\n >\n> = {\n free: {\n rateLimitRules: 1,\n periods: [10],\n customRules: 5,\n managedRulesets: false,\n cacheRules: 10,\n },\n pro: {\n rateLimitRules: 2,\n periods: [10, 60],\n customRules: 20,\n managedRulesets: true,\n cacheRules: 25,\n },\n business: {\n rateLimitRules: 5,\n periods: [10, 60, 120, 300, 600],\n customRules: 100,\n managedRulesets: true,\n cacheRules: 50,\n },\n enterprise: {\n rateLimitRules: 100,\n periods: [10, 60, 120, 300, 600, 3600],\n customRules: 1000,\n managedRulesets: true,\n cacheRules: 300,\n },\n}\n\nconst isPlan = (value: unknown): value is Plan =>\n typeof value === 'string' && Object.hasOwn(PLAN_LIMITS, value)\n\nexport interface PhasePlan {\n phase: Phase\n add: Rule[]\n /** `position` is set when the rule is out of place: its index on the zone, and the one `apply` writes it at. */\n change: Array<{ ref: string; from: Rule; to: Rule; position?: { from: number; to: number } }>\n remove: Rule[]\n}\n\n/** What `apply` would change (from `plan`) or changed (from `apply`), phase by phase. */\nexport interface SecurityPlan {\n phases: PhasePlan[]\n}\n\nexport const isEmptyPlan = (plan: SecurityPlan): boolean =>\n plan.phases.every((p) => p.add.length + p.change.length + p.remove.length === 0)\n\nexport interface CloudflareSecurityAdapter extends SecurityAdapter {\n drift(config: SecurityConfigLike): Promise<DriftReport>\n inspect(): Promise<SecurityState>\n plan(config: SecurityConfigLike): Promise<SecurityPlan>\n /** Writes each phase whose Nymbal rules differ, one by one; it rejects at the first refused write. */\n apply(config: SecurityConfigLike): Promise<SecurityPlan>\n}\n\n/** `desired` holds in `live`: every field `desired` sets, at any depth, has the same value there. */\nfunction holds(desired: unknown, live: unknown): boolean {\n if (Array.isArray(desired)) {\n return (\n Array.isArray(live) &&\n live.length === desired.length &&\n desired.every((d, i) => holds(d, live[i]))\n )\n }\n if (typeof desired === 'object' && desired !== null) {\n if (typeof live !== 'object' || live === null) return false\n return Object.entries(desired).every(([k, v]) => holds(v, (live as Record<string, unknown>)[k]))\n }\n return desired === live\n}\n\n/** The fields of `desired` that `live` does not hold. */\nconst changedFields = (desired: Rule, live: Rule): string[] =>\n Object.keys(desired).filter((field) => !holds(desired[field], live[field]))\n\nfunction byRef(rules: readonly Rule[]): Map<string, Rule> {\n return new Map(rules.filter(isNymbalRule).map((r) => [r.ref as string, r]))\n}\n\n/** A rule's identity within a phase: its ref when Nymbal's, its id when the owner's. */\nconst keyOf = (rule: Rule): string =>\n isNymbalRule(rule) ? `ref:${String(rule.ref)}` : `id:${String(rule.id)}`\n\n/**\n * The Nymbal rules a dashboard reorder put out of place, by ref, with their index on the zone and the one `apply`\n * writes them at. The kept rules are in place when they are one run, in the config's order; owner rules after that run\n * are the dashboard's default place for a new rule, so they move nothing. Indexes count only the rules both sides keep.\n */\nfunction misplaced(\n live: readonly Rule[],\n desired: readonly Rule[],\n): Map<string, { from: number; to: number }> {\n const wanted = byRef(desired)\n const kept = live.filter((r) => !isNymbalRule(r) || wanted.has(r.ref as string))\n const ours = kept.flatMap((r, i) => (isNymbalRule(r) ? [i] : []))\n const liveRefs = ours.map((i) => kept[i]!.ref as string)\n const present = new Set(liveRefs)\n const inOrder = desired.filter((r) => present.has(r.ref as string)).map((r) => r.ref as string)\n const oneRun = ours.length === 0 || ours[ours.length - 1]! - ours[0]! === ours.length - 1\n const out = new Map<string, { from: number; to: number }>()\n if (oneRun && liveRefs.every((ref, i) => ref === inOrder[i])) return out\n const written = nextRules(kept, desired).filter(\n (r) => !isNymbalRule(r) || present.has(r.ref as string),\n )\n const target = new Map(written.map((r, i) => [keyOf(r), i]))\n for (const i of ours) {\n const ref = kept[i]!.ref as string\n const to = target.get(keyOf(kept[i]!))!\n if (to !== i) out.set(ref, { from: i, to })\n }\n return out\n}\n\nfunction diffPhase(phase: Phase, live: readonly Rule[], desired: readonly Rule[]): PhasePlan {\n const current = byRef(live)\n const wanted = byRef(desired)\n const moved = misplaced(live, desired)\n const plan: PhasePlan = { phase, add: [], change: [], remove: [] }\n for (const [ref, to] of wanted) {\n const from = current.get(ref)\n const position = moved.get(ref)\n if (from === undefined) plan.add.push(to)\n else if (changedFields(to, from).length > 0 || position !== undefined) {\n plan.change.push({ ref, from, to, ...(position !== undefined && { position }) })\n }\n }\n for (const [ref, rule] of current) if (!wanted.has(ref)) plan.remove.push(rule)\n return plan\n}\n\n/** The rules to PUT: the zone's own, in their order, then Nymbal's, each keeping the id Cloudflare gave it. */\nfunction nextRules(live: readonly Rule[], desired: readonly Rule[]): Rule[] {\n const ids = new Map(live.filter(isNymbalRule).map((r) => [r.ref as string, r.id] as const))\n const own = live\n .filter((r) => !isNymbalRule(r))\n .map(({ version: _version, last_updated: _updated, ...rule }) => rule as Rule)\n const ours = desired.map((rule) => {\n const id = ids.get(rule.ref as string)\n return id === undefined ? rule : { ...rule, id }\n })\n return [...own, ...ours]\n}\n\nexport function createCloudflareSecurityAdapter(deps: {\n connection: CloudflareConnection\n logger: Logger\n}): CloudflareSecurityAdapter {\n const { connection, logger } = deps\n const client: CloudflareClient = createClient(connection)\n let lastValidatedAt = ''\n\n const livePhases = async (): Promise<Record<Phase, Rule[]>> => {\n const read = await Promise.all(PHASES.map((phase) => client.entrypoint(phase)))\n return Object.fromEntries(PHASES.map((phase, i) => [phase, read[i] ?? []])) as Record<\n Phase,\n Rule[]\n >\n }\n\n const desiredOf = (config: SecurityConfigLike) => desiredRules(config as EdgeRulesConfig)\n\n /** Errors a config has whatever the zone: keys, windows and geo codes. */\n function configErrors(config: EdgeRulesConfig): ValidationResult['errors'] {\n const errors: ValidationResult['errors'] = []\n for (const [key, entry] of Object.entries(config.rateLimit ?? {})) {\n if (!isEdgePath(key)) {\n errors.push({\n code: 'cloudflare.path',\n path: `rateLimit.${key}`,\n message: `rate limit key '${key}' is not a path (it must start with /), so it has no edge rule`,\n })\n }\n if (WINDOW_PERIODS[entry.window] === undefined) {\n errors.push({\n code: 'cloudflare.period',\n path: `rateLimit.${key}.window`,\n message: `window '${entry.window}' has no Cloudflare period; use one of ${Object.keys(WINDOW_PERIODS).join(', ')}`,\n })\n }\n }\n for (const code of config.geo?.block ?? []) {\n if (!/^[A-Z]{2}$/.test(code)) {\n errors.push({\n code: 'cloudflare.geo',\n path: 'geo.block',\n message: `'${code}' is not an ISO 3166 alpha-2 country code (two capital letters)`,\n })\n }\n }\n return errors\n }\n\n /** Errors the zone's plan and its own rules give the config. */\n function planErrors(\n plan: Plan,\n config: EdgeRulesConfig,\n live: Record<Phase, Rule[]>,\n ): ValidationResult['errors'] {\n const limits = PLAN_LIMITS[plan]\n const errors: ValidationResult['errors'] = []\n for (const [key, entry] of Object.entries(config.rateLimit ?? {})) {\n const period = WINDOW_PERIODS[entry.window]\n if (period !== undefined && !limits.periods.includes(period)) {\n errors.push({\n code: 'cloudflare.period',\n path: `rateLimit.${key}.window`,\n message: `window '${entry.window}' is not allowed on the ${plan} plan; it allows periods of ${limits.periods.join(', ')} s`,\n })\n }\n if (entry.action === 'throttle' && plan !== 'enterprise') {\n errors.push({\n code: 'cloudflare.action',\n path: `rateLimit.${key}.action`,\n message: `throttle needs the enterprise plan; the zone is on the ${plan} plan (use block or challenge)`,\n })\n }\n }\n const desired = desiredRules(config)\n const counted: Array<[Phase, number, string]> = [\n ['http_ratelimit', limits.rateLimitRules, 'rate limiting rules'],\n ['http_request_firewall_custom', limits.customRules, 'custom rules'],\n ['http_request_cache_settings', limits.cacheRules, 'cache rules'],\n ]\n for (const [phase, limit, what] of counted) {\n const own = live[phase].filter((r) => !isNymbalRule(r)).length\n const total = own + desired[phase].length\n if (total > limit) {\n errors.push({\n code: 'cloudflare.plan-limit',\n path: phase,\n message: `${phase} would hold ${total} ${what} (${own} of the zone's own, ${desired[phase].length} of Nymbal's); the ${plan} plan allows ${limit}`,\n })\n }\n }\n if (!limits.managedRulesets && desired.http_request_firewall_managed.length > 0) {\n errors.push({\n code: 'cloudflare.plan-limit',\n path: 'managedRulesets',\n message: `managed rulesets need the pro plan or above; the zone is on the ${plan} plan`,\n })\n }\n return errors\n }\n\n async function plan(config: SecurityConfigLike): Promise<SecurityPlan> {\n const live = await livePhases()\n const desired = desiredOf(config)\n return { phases: PHASES.map((phase) => diffPhase(phase, live[phase], desired[phase])) }\n }\n\n return {\n capabilities: () => ['headers', 'edge-rules'],\n producesEvents: [],\n consumesEvents: [],\n\n // Boot makes no request: the zone changes only by `nymbal security:apply`.\n initialize: () => Promise.resolve(),\n\n healthCheck() {\n return boundedHealthCheck(async () => {\n const zone = await client.zone()\n const plan = zone.plan?.legacy_id ?? 'unknown'\n return { status: 'healthy', detail: `zone ${zone.name ?? zone.id} on the ${plan} plan` }\n })\n },\n\n async applyMiddleware(app: HttpAdapter, config: SecurityConfigLike): Promise<void> {\n app.setDefaultHeaders(buildSecurityHeaders(config))\n logger.debug({}, 'security headers attached')\n },\n\n async validate(config: SecurityConfigLike): Promise<ValidationResult> {\n const edge = config as EdgeRulesConfig\n if (!hasCredentials(connection)) {\n const missing = [\n ...(connection.zoneId ? [] : ['zoneId']),\n ...(connection.apiToken ? [] : ['apiToken']),\n ]\n return {\n valid: false,\n errors: [\n {\n code: 'cloudflare.credentials',\n path: 'cloudflare',\n message: `security.cloudflare has no ${missing.join(' or ')} (CLOUDFLARE_ZONE_ID, CLOUDFLARE_API_TOKEN)`,\n },\n ],\n }\n }\n const errors = configErrors(edge)\n try {\n const zone = await client.zone()\n const plan = zone.plan?.legacy_id\n if (!isPlan(plan)) {\n errors.push({\n code: 'cloudflare.plan-limit',\n message: `the zone's plan '${String(plan)}' is not one Nymbal knows the limits of (free, pro, business, enterprise)`,\n })\n } else {\n errors.push(...planErrors(plan, edge, await livePhases()))\n }\n } catch (error) {\n if (!(error instanceof CloudflareCredentialsError)) throw error\n errors.push({ code: 'cloudflare.credentials', message: error.message })\n }\n lastValidatedAt = new Date().toISOString()\n return { valid: errors.length === 0, errors }\n },\n\n plan,\n\n async apply(config: SecurityConfigLike): Promise<SecurityPlan> {\n const desired = desiredOf(config)\n const written: PhasePlan[] = []\n for (const phase of PHASES) {\n const live = await client.entrypoint(phase)\n const change = diffPhase(phase, live ?? [], desired[phase])\n if (change.add.length + change.change.length + change.remove.length === 0) continue\n try {\n await client.putEntrypoint(phase, nextRules(live ?? [], desired[phase]))\n } catch (error) {\n const done = written.map((p) => p.phase)\n throw new AdapterError(\n 'cloudflare_apply',\n `writing ${phase} failed after ${done.length === 0 ? 'no phase' : done.join(', ')} was written: ${errorText(error)}`,\n { cause: error, context: { failed: phase, written: done } },\n )\n }\n logger.info(\n {\n phase,\n added: change.add.length,\n changed: change.change.length,\n removed: change.remove.length,\n },\n `wrote ${phase}`,\n )\n written.push(change)\n }\n return { phases: written }\n },\n\n async drift(config: SecurityConfigLike): Promise<DriftReport> {\n const live = await livePhases()\n const desired = desiredOf(config)\n const differences: DriftReport['differences'] = []\n for (const phase of PHASES) {\n const { add, change, remove } = diffPhase(phase, live[phase], desired[phase])\n for (const rule of add) {\n differences.push({ path: `${phase}/${rule.ref}`, configured: rule, actual: undefined })\n }\n for (const { ref, from, to, position } of change) {\n for (const field of changedFields(to, from)) {\n differences.push({\n path: `${phase}/${ref}/${field}`,\n configured: to[field],\n actual: from[field],\n })\n }\n if (position !== undefined) {\n differences.push({\n path: `${phase}/${ref}/position`,\n configured: position.to,\n actual: position.from,\n })\n }\n }\n for (const rule of remove) {\n differences.push({ path: `${phase}/${rule.ref}`, configured: undefined, actual: rule })\n }\n }\n return { drift: differences.length > 0, differences }\n },\n\n async inspect(): Promise<SecurityState> {\n const live = await livePhases()\n return {\n rulesApplied: PHASES.flatMap((phase) =>\n live[phase].filter(isNymbalRule).map((r) => r.ref as string),\n ),\n lastValidatedAt,\n }\n },\n }\n}\n","import { AdapterError, errorText } from '@nymbalhq/types'\nimport type { Phase, Rule } from './rules.js'\n\nexport const DEFAULT_API_BASE = 'https://api.cloudflare.com/client/v4'\nexport const REQUEST_TIMEOUT_MS = 5000\n\n/** The `security.cloudflare` block: the adapter's `ctx.config`. */\nexport interface CloudflareConnection {\n zoneId?: string\n apiToken?: string\n apiBase?: string\n}\n\nexport const CREDENTIALS_HINT =\n 'check security.cloudflare.zoneId and apiToken (CLOUDFLARE_ZONE_ID, CLOUDFLARE_API_TOKEN): the token needs ' +\n 'Zone › Zone › Read, Zone › Zone WAF › Edit and Zone › Cache Rules › Edit on the zone'\n\n/** The token or zone was refused (401 or 403), or the config has none. */\nexport class CloudflareCredentialsError extends AdapterError {\n constructor(detail: string, context: Record<string, unknown> = {}) {\n super('cloudflare_credentials', `Cloudflare: ${detail}; ${CREDENTIALS_HINT}`, { context })\n }\n}\n\ninterface Envelope<T> {\n success?: boolean\n errors?: Array<{ code?: number; message?: string }>\n result?: T\n}\n\nexport interface Zone {\n id: string\n name?: string\n plan?: { legacy_id?: string; name?: string }\n}\n\n/** The Cloudflare v4 API as the adapter uses it: the zone, and one phase's entrypoint ruleset. */\nexport interface CloudflareClient {\n zone(): Promise<Zone>\n /** The phase's rules, or undefined when the phase was never written (404). */\n entrypoint(phase: Phase): Promise<Rule[] | undefined>\n /** Replaces the phase's rules whole, creating the entrypoint when it is absent. */\n putEntrypoint(phase: Phase, rules: Rule[]): Promise<void>\n}\n\nexport function hasCredentials(\n connection: CloudflareConnection,\n): connection is Required<Pick<CloudflareConnection, 'zoneId' | 'apiToken'>> &\n CloudflareConnection {\n return Boolean(connection.zoneId) && Boolean(connection.apiToken)\n}\n\nfunction apiErrors(body: unknown): string {\n const errors = (body as Envelope<unknown> | null)?.errors ?? []\n const messages = errors.map((e) => e.message).filter((m): m is string => typeof m === 'string')\n return messages.length > 0 ? messages.join('; ') : 'no error message'\n}\n\nexport function createClient(connection: CloudflareConnection): CloudflareClient {\n const base = (connection.apiBase ?? DEFAULT_API_BASE).replace(/\\/+$/, '')\n const zonePath = `/zones/${encodeURIComponent(connection.zoneId ?? '')}`\n\n async function request(\n method: 'GET' | 'PUT',\n path: string,\n body?: unknown,\n ): Promise<{ status: number; body: unknown }> {\n if (!hasCredentials(connection)) {\n throw new CloudflareCredentialsError('no zone ID or API token is configured')\n }\n let response: Response\n let text: string\n try {\n response = await fetch(`${base}${path}`, {\n method,\n headers: {\n authorization: `Bearer ${connection.apiToken}`,\n ...(body !== undefined && { 'content-type': 'application/json' }),\n },\n ...(body !== undefined && { body: JSON.stringify(body) }),\n signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),\n })\n text = await response.text()\n } catch (error) {\n throw new AdapterError(\n 'cloudflare_unreachable',\n `Cloudflare API at ${base} did not answer ${method} ${path}: ${errorText(error)}`,\n { cause: error, context: { method, path } },\n )\n }\n let parsed: unknown\n try {\n parsed = text === '' ? undefined : (JSON.parse(text) as unknown)\n } catch {\n // A proxy's error page is not JSON: a failure still goes by its status, and a success is refused below.\n parsed = undefined\n }\n if (response.status === 401 || response.status === 403) {\n throw new CloudflareCredentialsError(\n `${method} ${path} was refused (${response.status}: ${apiErrors(parsed)})`,\n { status: response.status },\n )\n }\n if (response.ok && (typeof parsed !== 'object' || parsed === null)) {\n throw new AdapterError(\n 'cloudflare_api',\n `Cloudflare answered ${method} ${path} with ${response.status} and no JSON body`,\n { context: { method, path, status: response.status } },\n )\n }\n return { status: response.status, body: parsed }\n }\n\n const failed = (method: string, path: string, status: number, body: unknown): AdapterError =>\n new AdapterError(\n 'cloudflare_api',\n `Cloudflare answered ${method} ${path} with ${status}: ${apiErrors(body)}`,\n { context: { method, path, status } },\n )\n\n return {\n async zone() {\n const { status, body } = await request('GET', zonePath)\n if (status === 404) {\n throw new CloudflareCredentialsError(`zone ${connection.zoneId} was not found`, { status })\n }\n if (status !== 200) throw failed('GET', zonePath, status, body)\n return (body as Envelope<Zone>).result as Zone\n },\n async entrypoint(phase) {\n const path = `${zonePath}/rulesets/phases/${phase}/entrypoint`\n const { status, body } = await request('GET', path)\n if (status === 404) return undefined\n if (status !== 200) throw failed('GET', path, status, body)\n return (body as Envelope<{ rules?: Rule[] }>).result?.rules ?? []\n },\n async putEntrypoint(phase, rules) {\n const path = `${zonePath}/rulesets/phases/${phase}/entrypoint`\n const { status, body } = await request('PUT', path, { rules })\n if (status !== 200) throw failed('PUT', path, status, body)\n },\n }\n}\n","/** The entrypoint phases Nymbal writes, in the order `apply` writes them. */\nexport const PHASES = [\n 'http_ratelimit',\n 'http_request_firewall_custom',\n 'http_request_firewall_managed',\n 'http_request_cache_settings',\n] as const\nexport type Phase = (typeof PHASES)[number]\n\n/** A ruleset rule as the Cloudflare v4 API reads and writes it. */\nexport interface Rule {\n id?: string\n ref?: string\n description?: string\n expression: string\n action: string\n action_parameters?: Record<string, unknown>\n ratelimit?: {\n characteristics: string[]\n period: number\n requests_per_period: number\n mitigation_timeout: number\n }\n enabled?: boolean\n [key: string]: unknown\n}\n\nexport const NYMBAL_REF_PREFIX = 'nymbal:'\n\nexport const isNymbalRule = (rule: { ref?: unknown }): boolean =>\n typeof rule.ref === 'string' && rule.ref.startsWith(NYMBAL_REF_PREFIX)\n\n/** Each `rateLimit` window Cloudflare has a period for, in seconds. */\nexport const WINDOW_PERIODS: Readonly<Record<string, number>> = {\n '10s': 10,\n '1m': 60,\n '2m': 120,\n '5m': 300,\n '10m': 600,\n '1h': 3600,\n}\n\n/** Cloudflare's own managed rulesets, by the name `managedRulesets` uses. */\nexport const MANAGED_RULESET_IDS = {\n cloudflare: 'efb7b8c949ac4650a09736fc376e9aee',\n owasp: '4814384a9e5d4991b9815dcfc25d2f1f',\n} as const\nexport type ManagedRuleset = keyof typeof MANAGED_RULESET_IDS\n\nconst MANAGED_NAMES: Readonly<Record<ManagedRuleset, string>> = {\n cloudflare: 'Cloudflare Managed Ruleset',\n owasp: 'Cloudflare OWASP Core Ruleset',\n}\n\nexport interface RateLimitEntry {\n requests: number\n window: string\n action: 'block' | 'challenge' | 'throttle'\n}\n\nexport interface CustomRule {\n name: string\n expression: string\n action: 'block' | 'managed_challenge' | 'js_challenge' | 'log'\n}\n\n/** The parts of the `security` block the edge rules come from; every one may be absent. */\nexport interface EdgeRulesConfig {\n rateLimit?: Record<string, RateLimitEntry>\n geo?: { block?: string[] }\n customRules?: CustomRule[]\n managedRulesets?: string[]\n}\n\nconst quoted = (text: string): string => `\"${text.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"')}\"`\n\n/**\n * The edge match for an in-process limiter key: exact without `*`, else a wildcard with `**` as `*`. Cloudflare's `*`\n * crosses `/`, so a single `*` matches more at the edge than at the origin (`hasSingleStar`).\n */\nexport function pathExpression(key: string): string {\n return key.includes('*')\n ? `http.request.uri.path wildcard ${quoted(key.replace(/\\*+/g, '*'))}`\n : `http.request.uri.path eq ${quoted(key)}`\n}\n\n/** A key with a `*` that is not part of `**`: one segment at the origin, any number at the edge. */\nexport const hasSingleStar = (key: string): boolean => /(^|[^*])\\*([^*]|$)/.test(key)\n\n/** A rate limit key the edge can express: a path. */\nexport const isEdgePath = (key: string): boolean => key.startsWith('/')\n\nfunction rateLimitRule(key: string, entry: RateLimitEntry): Rule | undefined {\n const period = WINDOW_PERIODS[entry.window]\n if (!isEdgePath(key) || period === undefined) return undefined\n const [action, mitigationTimeout] =\n entry.action === 'challenge'\n ? ['managed_challenge', 0]\n : entry.action === 'throttle'\n ? ['block', 0]\n : ['block', period]\n return {\n ref: `${NYMBAL_REF_PREFIX}ratelimit:${key}`,\n description: `${NYMBAL_REF_PREFIX} rate limit ${key}`,\n expression: pathExpression(key),\n action,\n ratelimit: {\n characteristics: ['cf.colo.id', 'ip.src'],\n period,\n requests_per_period: entry.requests,\n mitigation_timeout: mitigationTimeout,\n },\n enabled: true,\n }\n}\n\n/**\n * The fragment routes' HTML is the same for every shopper and carries its own `s-maxage`, so the edge caches it for\n * the origin's TTLs; Cloudflare caches no HTML without a rule.\n */\nconst fragmentCacheRule = (): Rule => ({\n ref: `${NYMBAL_REF_PREFIX}cache:fragments`,\n description: `${NYMBAL_REF_PREFIX} cache the fragment routes`,\n expression: 'starts_with(http.request.uri.path, \"/fragments/\")',\n action: 'set_cache_settings',\n action_parameters: {\n cache: true,\n edge_ttl: { mode: 'respect_origin' },\n browser_ttl: { mode: 'respect_origin' },\n },\n enabled: true,\n})\n\n/**\n * The rules the config puts on the zone, by phase, every one carrying a `nymbal:` ref. Rate limits follow their keys'\n * sorted order and custom rules their config order, so the same config always gives the same rules. A key that is not\n * a path, or a window with no Cloudflare period, gives no rule: `validate` reports both.\n */\nexport function desiredRules(config: EdgeRulesConfig): Record<Phase, Rule[]> {\n const rateLimits = Object.entries(config.rateLimit ?? {})\n .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n .flatMap(([key, entry]) => rateLimitRule(key, entry) ?? [])\n\n const custom: Rule[] = []\n const blocked = config.geo?.block ?? []\n if (blocked.length > 0) {\n custom.push({\n ref: `${NYMBAL_REF_PREFIX}geo:block`,\n description: `${NYMBAL_REF_PREFIX} geo block ${blocked.join(' ')}`,\n expression: `ip.src.country in {${blocked.map(quoted).join(' ')}}`,\n action: 'block',\n enabled: true,\n })\n }\n for (const rule of config.customRules ?? []) {\n custom.push({\n ref: `${NYMBAL_REF_PREFIX}custom:${rule.name}`,\n description: `${NYMBAL_REF_PREFIX} ${rule.name}`,\n expression: rule.expression,\n action: rule.action,\n enabled: true,\n })\n }\n\n const managed = [...new Set(config.managedRulesets ?? [])]\n .filter((name): name is ManagedRuleset => Object.hasOwn(MANAGED_RULESET_IDS, name))\n .map(\n (name): Rule => ({\n ref: `${NYMBAL_REF_PREFIX}managed:${name}`,\n description: `${NYMBAL_REF_PREFIX} ${MANAGED_NAMES[name]}`,\n expression: 'true',\n action: 'execute',\n action_parameters: { id: MANAGED_RULESET_IDS[name] },\n enabled: true,\n }),\n )\n\n return {\n http_ratelimit: rateLimits,\n http_request_firewall_custom: custom,\n http_request_firewall_managed: managed,\n http_request_cache_settings: [fragmentCacheRule()],\n }\n}\n"],"mappings":"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","names":["AdapterError","errorText","plan","AdapterError","errorText"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nymbalhq/security-cloudflare",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Cloudflare WAF for Nymbal: the `cloudflare` security adapter, with rules applied by command",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/nymbalhq/nymbal.git",
|
|
9
|
+
"directory": "packages/security-cloudflare"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22"
|
|
17
|
+
},
|
|
18
|
+
"main": "./dist/index.js",
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./recipes/*": "./recipes/*"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"recipes"
|
|
30
|
+
],
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@nymbalhq/types": "1.2.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@typescript/native": "npm:typescript@^7.0.2",
|
|
36
|
+
"fast-check": "^4.10.2",
|
|
37
|
+
"tsup": "^9.0.0",
|
|
38
|
+
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
39
|
+
"vitest": "^3.0.0"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsup",
|
|
43
|
+
"dev": "tsup --watch",
|
|
44
|
+
"typecheck": "tsc -p tsconfig.json --noEmit --rootDir ../.."
|
|
45
|
+
}
|
|
6
46
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cloudflare",
|
|
3
|
+
"summary": "Put the store's rate limits, geo blocks, custom rules and managed rulesets on its Cloudflare zone.",
|
|
4
|
+
"install": ["@nymbalhq/security-cloudflare"],
|
|
5
|
+
"set": [
|
|
6
|
+
{ "path": "security.adapter", "value": "cloudflare" },
|
|
7
|
+
{
|
|
8
|
+
"path": "security.cloudflare",
|
|
9
|
+
"value": {
|
|
10
|
+
"zoneId": { "env": "CLOUDFLARE_ZONE_ID" },
|
|
11
|
+
"apiToken": { "env": "CLOUDFLARE_API_TOKEN" }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"env": [
|
|
16
|
+
{
|
|
17
|
+
"name": "CLOUDFLARE_ZONE_ID",
|
|
18
|
+
"example": "",
|
|
19
|
+
"required": true,
|
|
20
|
+
"comment": "The zone's ID (Cloudflare dashboard, the domain's Overview page, API section)"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "CLOUDFLARE_API_TOKEN",
|
|
24
|
+
"example": "",
|
|
25
|
+
"required": true,
|
|
26
|
+
"comment": "An API token scoped to that zone only, with Zone › Zone › Read, Zone › Zone WAF › Edit and Zone › Cache Rules › Edit"
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"next": [
|
|
30
|
+
"Create the API token in the Cloudflare dashboard (My Profile, API Tokens) with three permissions on this zone only: Zone › Zone › Read, Zone › Zone WAF › Edit and Zone › Cache Rules › Edit.",
|
|
31
|
+
"Behind Cloudflare every request reaches the API from an edge address: set http.trustProxy: true so the in-process rate limiter keys on the client, not on the edge.",
|
|
32
|
+
"The zone's plan limits security.rateLimit: the Free plan allows one rate limit with a 10s window, Pro two with 10s or 1m (the scaffold's four fit Business and up). A throttle action needs the Enterprise plan: on Free, Pro or Business change the scaffold's two throttle entries (/api/*, /webhooks/*) to block or challenge. nymbal security:plan names each limit the plan refuses.",
|
|
33
|
+
"Run nymbal security:plan to see the rules, then nymbal security:apply to put them on the zone; nymbal security:drift reports a dashboard edit."
|
|
34
|
+
]
|
|
35
|
+
}
|
package/README.md
DELETED