@ai-sdk/sandbox-vercel 1.0.70 → 1.0.72
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/CHANGELOG.md +18 -0
- package/README.md +8 -1
- package/dist/index.js +531 -37
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/vercel-network-policy-manager.ts +760 -0
- package/src/vercel-network-sandbox-session.ts +34 -42
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HarnessCapabilityUnsupportedError,
|
|
3
|
+
type HarnessV1NetworkPolicy,
|
|
4
|
+
type HarnessV1RequestTransformation,
|
|
5
|
+
} from '@ai-sdk/harness';
|
|
6
|
+
import type {
|
|
7
|
+
NetworkPolicy,
|
|
8
|
+
NetworkPolicyKeyValueMatcher,
|
|
9
|
+
NetworkPolicyMatcher,
|
|
10
|
+
NetworkPolicyRule,
|
|
11
|
+
Sandbox,
|
|
12
|
+
} from '@vercel/sandbox';
|
|
13
|
+
|
|
14
|
+
const VERCEL_PROVIDER_ID = 'vercel-sandbox';
|
|
15
|
+
|
|
16
|
+
type NetworkAccessPolicy =
|
|
17
|
+
| { readonly mode: 'allow-all' }
|
|
18
|
+
| { readonly mode: 'deny-all' }
|
|
19
|
+
| {
|
|
20
|
+
readonly mode: 'custom';
|
|
21
|
+
readonly allowedHosts: ReadonlyArray<string>;
|
|
22
|
+
readonly allowedCIDRs: ReadonlyArray<string>;
|
|
23
|
+
readonly deniedCIDRs: ReadonlyArray<string>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type ForwardRule = {
|
|
27
|
+
readonly host: string;
|
|
28
|
+
readonly match?: NetworkPolicyRule['match'];
|
|
29
|
+
readonly forwardURL: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type ManagedPolicyState = {
|
|
33
|
+
readonly accessPolicy: NetworkAccessPolicy;
|
|
34
|
+
readonly requestTransformations: ReadonlyArray<HarnessV1RequestTransformation>;
|
|
35
|
+
readonly forwardRules: ReadonlyArray<ForwardRule>;
|
|
36
|
+
readonly effectivePolicy: NetworkPolicy;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type CurrentPolicyInspection = {
|
|
40
|
+
readonly accessPolicy: NetworkAccessPolicy;
|
|
41
|
+
readonly forwardRules: ReadonlyArray<ForwardRule>;
|
|
42
|
+
readonly requestTransformationHosts: ReadonlyArray<string>;
|
|
43
|
+
readonly currentPolicy: NetworkPolicy;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Owns the complete Vercel Sandbox network-policy value for one live session.
|
|
48
|
+
* Vercel combines network access, request transformations, and forwarding in a
|
|
49
|
+
* single structure, so every mutation must be composed here before replacing
|
|
50
|
+
* that structure through `sandbox.update()`.
|
|
51
|
+
*/
|
|
52
|
+
export class VercelNetworkPolicyManager {
|
|
53
|
+
readonly #sandbox: Sandbox;
|
|
54
|
+
#state: ManagedPolicyState | undefined;
|
|
55
|
+
#mutationQueue: Promise<void> = Promise.resolve();
|
|
56
|
+
|
|
57
|
+
constructor({ sandbox }: { sandbox: Sandbox }) {
|
|
58
|
+
this.#sandbox = sandbox;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
setNetworkPolicy(policy: HarnessV1NetworkPolicy): Promise<void> {
|
|
62
|
+
return this.#enqueueMutation(async () => {
|
|
63
|
+
const inspection = this.#inspectPolicy();
|
|
64
|
+
if (
|
|
65
|
+
this.#state == null &&
|
|
66
|
+
inspection.requestTransformationHosts.length > 0
|
|
67
|
+
) {
|
|
68
|
+
throw createPolicyConflictError(
|
|
69
|
+
'Cannot set the network policy because the current Vercel Sandbox policy contains request transformations whose redacted values cannot be preserved safely. Replace them explicitly with setRequestTransformations() or rehydrate them with addRequestTransformations() first.',
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
await this.#applyState({
|
|
74
|
+
accessPolicy: toNetworkAccessPolicy(policy),
|
|
75
|
+
requestTransformations:
|
|
76
|
+
this.#state?.requestTransformations.map(cloneRequestTransformation) ??
|
|
77
|
+
[],
|
|
78
|
+
forwardRules: inspection.forwardRules.map(cloneForwardRule),
|
|
79
|
+
currentPolicy: inspection.currentPolicy,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
setRequestTransformations(
|
|
85
|
+
transformations: ReadonlyArray<HarnessV1RequestTransformation>,
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
return this.#enqueueMutation(async () => {
|
|
88
|
+
const inspection = this.#inspectPolicy();
|
|
89
|
+
await this.#applyState({
|
|
90
|
+
accessPolicy: inspection.accessPolicy,
|
|
91
|
+
requestTransformations: transformations.map(cloneRequestTransformation),
|
|
92
|
+
forwardRules: inspection.forwardRules.map(cloneForwardRule),
|
|
93
|
+
currentPolicy: inspection.currentPolicy,
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
addRequestTransformations(
|
|
99
|
+
transformations: ReadonlyArray<HarnessV1RequestTransformation>,
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
return this.#enqueueMutation(async () => {
|
|
102
|
+
const inspection = this.#inspectPolicy();
|
|
103
|
+
const incomingTransformations = transformations.map(
|
|
104
|
+
cloneRequestTransformation,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
if (this.#state == null) {
|
|
108
|
+
const incomingPolicy = composeNetworkPolicy({
|
|
109
|
+
accessPolicy: inspection.accessPolicy,
|
|
110
|
+
requestTransformations: incomingTransformations,
|
|
111
|
+
forwardRules: inspection.forwardRules,
|
|
112
|
+
});
|
|
113
|
+
const incomingHosts = getRequestTransformationHosts(incomingPolicy);
|
|
114
|
+
|
|
115
|
+
/*
|
|
116
|
+
* Vercel redacts transformed header values when a policy is read back.
|
|
117
|
+
* It can also normalize rule details, so initialization and resume can
|
|
118
|
+
* only attribute existing transformations by their materialized hosts.
|
|
119
|
+
* External transformations for the same hosts are indistinguishable
|
|
120
|
+
* and are necessarily treated as managed by this call.
|
|
121
|
+
*/
|
|
122
|
+
if (
|
|
123
|
+
!isHostSetSubset({
|
|
124
|
+
subset: inspection.requestTransformationHosts,
|
|
125
|
+
superset: incomingHosts,
|
|
126
|
+
})
|
|
127
|
+
) {
|
|
128
|
+
throw createPolicyConflictError(
|
|
129
|
+
'Cannot add request transformations because the current Vercel Sandbox policy contains request transformations that cannot be attributed to this call. Their header values are redacted, so preserving them safely is not possible.',
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
await this.#applyState({
|
|
135
|
+
accessPolicy: inspection.accessPolicy,
|
|
136
|
+
requestTransformations:
|
|
137
|
+
this.#state == null
|
|
138
|
+
? incomingTransformations
|
|
139
|
+
: [
|
|
140
|
+
...this.#state.requestTransformations.map(
|
|
141
|
+
cloneRequestTransformation,
|
|
142
|
+
),
|
|
143
|
+
...incomingTransformations,
|
|
144
|
+
],
|
|
145
|
+
forwardRules: inspection.forwardRules.map(cloneForwardRule),
|
|
146
|
+
currentPolicy: inspection.currentPolicy,
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#enqueueMutation(operation: () => Promise<void>): Promise<void> {
|
|
152
|
+
/*
|
|
153
|
+
* Each mutation derives a complete replacement from private state. The
|
|
154
|
+
* whole inspect, compose, update, and commit sequence must therefore run in
|
|
155
|
+
* invocation order, while a failed operation must not poison the queue.
|
|
156
|
+
*/
|
|
157
|
+
const result = this.#mutationQueue.then(operation);
|
|
158
|
+
this.#mutationQueue = result.then(
|
|
159
|
+
() => undefined,
|
|
160
|
+
() => undefined,
|
|
161
|
+
);
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#inspectPolicy(): CurrentPolicyInspection {
|
|
166
|
+
/*
|
|
167
|
+
* Once initialized, this manager is the sole supported owner of the
|
|
168
|
+
* session's network policy. Direct native Sandbox policy mutations can
|
|
169
|
+
* make the private desired state stale and are intentionally not tracked.
|
|
170
|
+
*/
|
|
171
|
+
if (this.#state != null) {
|
|
172
|
+
return {
|
|
173
|
+
accessPolicy: this.#state.accessPolicy,
|
|
174
|
+
forwardRules: this.#state.forwardRules,
|
|
175
|
+
requestTransformationHosts: [],
|
|
176
|
+
currentPolicy: this.#state.effectivePolicy,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return inspectCurrentPolicy(
|
|
181
|
+
this.#sandbox.currentSession().networkPolicy ?? 'allow-all',
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async #applyState({
|
|
186
|
+
accessPolicy,
|
|
187
|
+
requestTransformations,
|
|
188
|
+
forwardRules,
|
|
189
|
+
currentPolicy,
|
|
190
|
+
}: {
|
|
191
|
+
accessPolicy: NetworkAccessPolicy;
|
|
192
|
+
requestTransformations: ReadonlyArray<HarnessV1RequestTransformation>;
|
|
193
|
+
forwardRules: ReadonlyArray<ForwardRule>;
|
|
194
|
+
currentPolicy: NetworkPolicy;
|
|
195
|
+
}): Promise<void> {
|
|
196
|
+
const effectivePolicy = composeNetworkPolicy({
|
|
197
|
+
accessPolicy,
|
|
198
|
+
requestTransformations,
|
|
199
|
+
forwardRules,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
if (
|
|
203
|
+
!areNetworkPoliciesEqual({
|
|
204
|
+
first: currentPolicy,
|
|
205
|
+
second: effectivePolicy,
|
|
206
|
+
})
|
|
207
|
+
) {
|
|
208
|
+
await this.#sandbox.update({ networkPolicy: effectivePolicy });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
this.#state = {
|
|
212
|
+
accessPolicy: cloneNetworkAccessPolicy(accessPolicy),
|
|
213
|
+
requestTransformations: requestTransformations.map(
|
|
214
|
+
cloneRequestTransformation,
|
|
215
|
+
),
|
|
216
|
+
forwardRules: forwardRules.map(cloneForwardRule),
|
|
217
|
+
effectivePolicy,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function inspectCurrentPolicy(policy: NetworkPolicy): CurrentPolicyInspection {
|
|
223
|
+
if (policy === 'allow-all' || policy === 'deny-all') {
|
|
224
|
+
return {
|
|
225
|
+
accessPolicy: { mode: policy },
|
|
226
|
+
forwardRules: [],
|
|
227
|
+
requestTransformationHosts: [],
|
|
228
|
+
currentPolicy: policy,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const allowedHosts = Array.isArray(policy.allow)
|
|
233
|
+
? [...policy.allow]
|
|
234
|
+
: Object.keys(policy.allow ?? {});
|
|
235
|
+
const forwardRules: ForwardRule[] = [];
|
|
236
|
+
const requestTransformationHosts = new Set<string>();
|
|
237
|
+
|
|
238
|
+
/*
|
|
239
|
+
* Forwarding URLs and matchers survive Vercel's policy readback and can be
|
|
240
|
+
* retained verbatim. Transformed header values do not, so transformations
|
|
241
|
+
* are represented only by fingerprints until a caller supplies the values.
|
|
242
|
+
*/
|
|
243
|
+
if (policy.allow != null && !Array.isArray(policy.allow)) {
|
|
244
|
+
for (const [host, rules] of Object.entries(policy.allow)) {
|
|
245
|
+
for (const rule of rules) {
|
|
246
|
+
if (hasRequestTransformation(rule)) {
|
|
247
|
+
requestTransformationHosts.add(host.toLowerCase());
|
|
248
|
+
}
|
|
249
|
+
if (rule.forwardURL != null) {
|
|
250
|
+
forwardRules.push({
|
|
251
|
+
host,
|
|
252
|
+
...(rule.match == null ? {} : { match: cloneMatch(rule.match) }),
|
|
253
|
+
forwardURL: rule.forwardURL,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
accessPolicy: {
|
|
262
|
+
mode: 'custom',
|
|
263
|
+
allowedHosts: minimizeHostPatterns(allowedHosts),
|
|
264
|
+
allowedCIDRs: [...(policy.subnets?.allow ?? [])],
|
|
265
|
+
deniedCIDRs: [...(policy.subnets?.deny ?? [])],
|
|
266
|
+
},
|
|
267
|
+
forwardRules,
|
|
268
|
+
requestTransformationHosts: [...requestTransformationHosts],
|
|
269
|
+
currentPolicy: cloneNetworkPolicy(policy),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function toNetworkAccessPolicy(
|
|
274
|
+
policy: HarnessV1NetworkPolicy,
|
|
275
|
+
): NetworkAccessPolicy {
|
|
276
|
+
switch (policy.mode) {
|
|
277
|
+
case 'allow-all':
|
|
278
|
+
return { mode: 'allow-all' };
|
|
279
|
+
case 'deny-all':
|
|
280
|
+
return { mode: 'deny-all' };
|
|
281
|
+
case 'custom': {
|
|
282
|
+
const allowedHosts = [...(policy.allowedHosts ?? [])];
|
|
283
|
+
const allowedCIDRs = [...(policy.allowedCIDRs ?? [])];
|
|
284
|
+
const deniedCIDRs = [...(policy.deniedCIDRs ?? [])];
|
|
285
|
+
if (
|
|
286
|
+
allowedHosts.length === 0 &&
|
|
287
|
+
allowedCIDRs.length === 0 &&
|
|
288
|
+
deniedCIDRs.length === 0
|
|
289
|
+
) {
|
|
290
|
+
throw createPolicyConflictError(
|
|
291
|
+
'Custom network policy requires at least one of allowedHosts, allowedCIDRs, or deniedCIDRs to be non-empty.',
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
mode: 'custom',
|
|
296
|
+
allowedHosts,
|
|
297
|
+
allowedCIDRs,
|
|
298
|
+
deniedCIDRs,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function composeNetworkPolicy({
|
|
305
|
+
accessPolicy,
|
|
306
|
+
requestTransformations,
|
|
307
|
+
forwardRules,
|
|
308
|
+
}: {
|
|
309
|
+
accessPolicy: NetworkAccessPolicy;
|
|
310
|
+
requestTransformations: ReadonlyArray<HarnessV1RequestTransformation>;
|
|
311
|
+
forwardRules: ReadonlyArray<ForwardRule>;
|
|
312
|
+
}): NetworkPolicy {
|
|
313
|
+
/*
|
|
314
|
+
* Access is authoritative even though Vercel stores all three concepts in
|
|
315
|
+
* one allow map. Rules without a hostname intersection are omitted from the
|
|
316
|
+
* effective policy but remain in private desired state, so a later access
|
|
317
|
+
* change can activate them. CIDR access never activates hostname rules.
|
|
318
|
+
*/
|
|
319
|
+
if (accessPolicy.mode === 'deny-all') {
|
|
320
|
+
return 'deny-all';
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const rulesByHost = new Map<string, NetworkPolicyRule[]>();
|
|
324
|
+
const allowedHosts =
|
|
325
|
+
accessPolicy.mode === 'allow-all' ? ['*'] : accessPolicy.allowedHosts;
|
|
326
|
+
|
|
327
|
+
for (const host of allowedHosts) {
|
|
328
|
+
rulesByHost.set(host, []);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
for (const transformation of requestTransformations) {
|
|
332
|
+
for (const host of getActiveHostPatterns({
|
|
333
|
+
allowedHosts,
|
|
334
|
+
ruleHost: transformation.match.host,
|
|
335
|
+
})) {
|
|
336
|
+
appendRule({
|
|
337
|
+
rulesByHost,
|
|
338
|
+
host,
|
|
339
|
+
rule: toVercelRequestTransformationRule(transformation),
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/*
|
|
345
|
+
* Transformations and forwarding rules for one host must share the same
|
|
346
|
+
* Vercel allow-map array. Transformations are inserted first to match the
|
|
347
|
+
* ordering produced when the Vercel SDK reconstructs a policy.
|
|
348
|
+
*/
|
|
349
|
+
for (const forwardRule of forwardRules) {
|
|
350
|
+
for (const host of getActiveHostPatterns({
|
|
351
|
+
allowedHosts,
|
|
352
|
+
ruleHost: forwardRule.host,
|
|
353
|
+
})) {
|
|
354
|
+
appendRule({
|
|
355
|
+
rulesByHost,
|
|
356
|
+
host,
|
|
357
|
+
rule: {
|
|
358
|
+
...(forwardRule.match == null
|
|
359
|
+
? {}
|
|
360
|
+
: { match: cloneMatch(forwardRule.match) }),
|
|
361
|
+
forwardURL: forwardRule.forwardURL,
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const hasActiveRules = [...rulesByHost.values()].some(
|
|
368
|
+
rules => rules.length > 0,
|
|
369
|
+
);
|
|
370
|
+
if (!hasActiveRules) {
|
|
371
|
+
return toVercelAccessPolicy(accessPolicy);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
allow: Object.fromEntries(rulesByHost),
|
|
376
|
+
...(accessPolicy.mode !== 'custom' ? {} : toVercelSubnets(accessPolicy)),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function toVercelAccessPolicy(
|
|
381
|
+
accessPolicy: NetworkAccessPolicy,
|
|
382
|
+
): NetworkPolicy {
|
|
383
|
+
if (accessPolicy.mode === 'allow-all' || accessPolicy.mode === 'deny-all') {
|
|
384
|
+
return accessPolicy.mode;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
...(accessPolicy.allowedHosts.length === 0
|
|
389
|
+
? {}
|
|
390
|
+
: { allow: [...accessPolicy.allowedHosts] }),
|
|
391
|
+
...toVercelSubnets(accessPolicy),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function toVercelSubnets(
|
|
396
|
+
accessPolicy: Extract<NetworkAccessPolicy, { mode: 'custom' }>,
|
|
397
|
+
): Pick<Exclude<NetworkPolicy, string>, 'subnets'> {
|
|
398
|
+
if (
|
|
399
|
+
accessPolicy.allowedCIDRs.length === 0 &&
|
|
400
|
+
accessPolicy.deniedCIDRs.length === 0
|
|
401
|
+
) {
|
|
402
|
+
return {};
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
subnets: {
|
|
406
|
+
...(accessPolicy.allowedCIDRs.length === 0
|
|
407
|
+
? {}
|
|
408
|
+
: { allow: [...accessPolicy.allowedCIDRs] }),
|
|
409
|
+
...(accessPolicy.deniedCIDRs.length === 0
|
|
410
|
+
? {}
|
|
411
|
+
: { deny: [...accessPolicy.deniedCIDRs] }),
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function getActiveHostPatterns({
|
|
417
|
+
allowedHosts,
|
|
418
|
+
ruleHost,
|
|
419
|
+
}: {
|
|
420
|
+
allowedHosts: ReadonlyArray<string>;
|
|
421
|
+
ruleHost: string;
|
|
422
|
+
}): ReadonlyArray<string> {
|
|
423
|
+
return minimizeHostPatterns(
|
|
424
|
+
allowedHosts.flatMap(allowedHost => {
|
|
425
|
+
const intersection = intersectHostPatterns({
|
|
426
|
+
first: allowedHost,
|
|
427
|
+
second: ruleHost,
|
|
428
|
+
});
|
|
429
|
+
return intersection == null ? [] : [intersection];
|
|
430
|
+
}),
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function intersectHostPatterns({
|
|
435
|
+
first,
|
|
436
|
+
second,
|
|
437
|
+
}: {
|
|
438
|
+
first: string;
|
|
439
|
+
second: string;
|
|
440
|
+
}): string | undefined {
|
|
441
|
+
if (isHostPatternSubset({ candidate: first, container: second })) {
|
|
442
|
+
return first;
|
|
443
|
+
}
|
|
444
|
+
if (isHostPatternSubset({ candidate: second, container: first })) {
|
|
445
|
+
return second;
|
|
446
|
+
}
|
|
447
|
+
return undefined;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function isHostPatternSubset({
|
|
451
|
+
candidate,
|
|
452
|
+
container,
|
|
453
|
+
}: {
|
|
454
|
+
candidate: string;
|
|
455
|
+
container: string;
|
|
456
|
+
}): boolean {
|
|
457
|
+
const normalizedCandidate = candidate.toLowerCase();
|
|
458
|
+
const normalizedContainer = container.toLowerCase();
|
|
459
|
+
if (
|
|
460
|
+
normalizedCandidate === normalizedContainer ||
|
|
461
|
+
normalizedContainer === '*'
|
|
462
|
+
) {
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
if (normalizedCandidate === '*' || !normalizedContainer.startsWith('*')) {
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const containerSuffix = normalizedContainer.slice(1);
|
|
470
|
+
return normalizedCandidate.startsWith('*')
|
|
471
|
+
? normalizedCandidate.slice(1).endsWith(containerSuffix)
|
|
472
|
+
: normalizedCandidate.endsWith(containerSuffix);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function minimizeHostPatterns(
|
|
476
|
+
patterns: ReadonlyArray<string>,
|
|
477
|
+
): ReadonlyArray<string> {
|
|
478
|
+
const uniquePatterns = [...new Set(patterns)];
|
|
479
|
+
return uniquePatterns.filter(
|
|
480
|
+
pattern =>
|
|
481
|
+
!uniquePatterns.some(
|
|
482
|
+
other =>
|
|
483
|
+
other !== pattern &&
|
|
484
|
+
isHostPatternSubset({ candidate: pattern, container: other }),
|
|
485
|
+
),
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function appendRule({
|
|
490
|
+
rulesByHost,
|
|
491
|
+
host,
|
|
492
|
+
rule,
|
|
493
|
+
}: {
|
|
494
|
+
rulesByHost: Map<string, NetworkPolicyRule[]>;
|
|
495
|
+
host: string;
|
|
496
|
+
rule: NetworkPolicyRule;
|
|
497
|
+
}): void {
|
|
498
|
+
const rules = rulesByHost.get(host) ?? [];
|
|
499
|
+
rules.push(rule);
|
|
500
|
+
rulesByHost.set(host, rules);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function toVercelRequestTransformationRule(
|
|
504
|
+
transformation: HarnessV1RequestTransformation,
|
|
505
|
+
): NetworkPolicyRule {
|
|
506
|
+
const {
|
|
507
|
+
host: _host,
|
|
508
|
+
method,
|
|
509
|
+
path,
|
|
510
|
+
queryString,
|
|
511
|
+
headers,
|
|
512
|
+
} = transformation.match;
|
|
513
|
+
const match = {
|
|
514
|
+
...(path == null ? {} : { path: cloneMatcher(path) }),
|
|
515
|
+
...(method == null ? {} : { method: [...method] }),
|
|
516
|
+
...(queryString == null
|
|
517
|
+
? {}
|
|
518
|
+
: { queryString: queryString.map(cloneKeyValueMatcher) }),
|
|
519
|
+
...(headers == null ? {} : { headers: headers.map(cloneKeyValueMatcher) }),
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
return {
|
|
523
|
+
...(Object.keys(match).length === 0 ? {} : { match }),
|
|
524
|
+
transform: [{ headers: { ...transformation.transform.headers } }],
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function hasRequestTransformation(rule: NetworkPolicyRule): boolean {
|
|
529
|
+
return (
|
|
530
|
+
rule.transform?.some(
|
|
531
|
+
transformation =>
|
|
532
|
+
transformation.headers != null &&
|
|
533
|
+
Object.keys(transformation.headers).length > 0,
|
|
534
|
+
) ?? false
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function getRequestTransformationHosts(
|
|
539
|
+
policy: NetworkPolicy,
|
|
540
|
+
): ReadonlyArray<string> {
|
|
541
|
+
if (
|
|
542
|
+
policy === 'allow-all' ||
|
|
543
|
+
policy === 'deny-all' ||
|
|
544
|
+
policy.allow == null ||
|
|
545
|
+
Array.isArray(policy.allow)
|
|
546
|
+
) {
|
|
547
|
+
return [];
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const hosts = new Set<string>();
|
|
551
|
+
for (const [host, rules] of Object.entries(policy.allow)) {
|
|
552
|
+
for (const rule of rules) {
|
|
553
|
+
if (hasRequestTransformation(rule)) {
|
|
554
|
+
hosts.add(host.toLowerCase());
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return [...hosts];
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function isHostSetSubset({
|
|
562
|
+
subset,
|
|
563
|
+
superset,
|
|
564
|
+
}: {
|
|
565
|
+
subset: ReadonlyArray<string>;
|
|
566
|
+
superset: ReadonlyArray<string>;
|
|
567
|
+
}): boolean {
|
|
568
|
+
const allowedHosts = new Set(superset.map(host => host.toLowerCase()));
|
|
569
|
+
return subset.every(host => allowedHosts.has(host.toLowerCase()));
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function areNetworkPoliciesEqual({
|
|
573
|
+
first,
|
|
574
|
+
second,
|
|
575
|
+
}: {
|
|
576
|
+
first: NetworkPolicy;
|
|
577
|
+
second: NetworkPolicy;
|
|
578
|
+
}): boolean {
|
|
579
|
+
return stableSerialize(first) === stableSerialize(second);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function stableSerialize(value: unknown): string {
|
|
583
|
+
if (Array.isArray(value)) {
|
|
584
|
+
return `[${value.map(stableSerialize).join(',')}]`;
|
|
585
|
+
}
|
|
586
|
+
if (value != null && typeof value === 'object') {
|
|
587
|
+
return `{${Object.entries(value)
|
|
588
|
+
.filter(([, entryValue]) => entryValue !== undefined)
|
|
589
|
+
.sort(([firstKey], [secondKey]) => firstKey.localeCompare(secondKey))
|
|
590
|
+
.map(
|
|
591
|
+
([key, entryValue]) =>
|
|
592
|
+
`${JSON.stringify(key)}:${stableSerialize(entryValue)}`,
|
|
593
|
+
)
|
|
594
|
+
.join(',')}}`;
|
|
595
|
+
}
|
|
596
|
+
return JSON.stringify(value);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function cloneRequestTransformation(
|
|
600
|
+
transformation: HarnessV1RequestTransformation,
|
|
601
|
+
): HarnessV1RequestTransformation {
|
|
602
|
+
return {
|
|
603
|
+
match: {
|
|
604
|
+
host: transformation.match.host,
|
|
605
|
+
...(transformation.match.path == null
|
|
606
|
+
? {}
|
|
607
|
+
: { path: cloneHarnessMatcher(transformation.match.path) }),
|
|
608
|
+
...(transformation.match.method == null
|
|
609
|
+
? {}
|
|
610
|
+
: { method: [...transformation.match.method] }),
|
|
611
|
+
...(transformation.match.queryString == null
|
|
612
|
+
? {}
|
|
613
|
+
: {
|
|
614
|
+
queryString: transformation.match.queryString.map(
|
|
615
|
+
cloneHarnessKeyValueMatcher,
|
|
616
|
+
),
|
|
617
|
+
}),
|
|
618
|
+
...(transformation.match.headers == null
|
|
619
|
+
? {}
|
|
620
|
+
: {
|
|
621
|
+
headers: transformation.match.headers.map(
|
|
622
|
+
cloneHarnessKeyValueMatcher,
|
|
623
|
+
),
|
|
624
|
+
}),
|
|
625
|
+
},
|
|
626
|
+
transform: { headers: { ...transformation.transform.headers } },
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function cloneForwardRule(rule: ForwardRule): ForwardRule {
|
|
631
|
+
return {
|
|
632
|
+
host: rule.host,
|
|
633
|
+
...(rule.match == null ? {} : { match: cloneMatch(rule.match) }),
|
|
634
|
+
forwardURL: rule.forwardURL,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function cloneNetworkAccessPolicy(
|
|
639
|
+
policy: NetworkAccessPolicy,
|
|
640
|
+
): NetworkAccessPolicy {
|
|
641
|
+
if (policy.mode === 'allow-all' || policy.mode === 'deny-all') {
|
|
642
|
+
return { mode: policy.mode };
|
|
643
|
+
}
|
|
644
|
+
return {
|
|
645
|
+
mode: 'custom',
|
|
646
|
+
allowedHosts: [...policy.allowedHosts],
|
|
647
|
+
allowedCIDRs: [...policy.allowedCIDRs],
|
|
648
|
+
deniedCIDRs: [...policy.deniedCIDRs],
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function cloneNetworkPolicy(policy: NetworkPolicy): NetworkPolicy {
|
|
653
|
+
if (policy === 'allow-all' || policy === 'deny-all') {
|
|
654
|
+
return policy;
|
|
655
|
+
}
|
|
656
|
+
return {
|
|
657
|
+
...(policy.allow == null
|
|
658
|
+
? {}
|
|
659
|
+
: Array.isArray(policy.allow)
|
|
660
|
+
? { allow: [...policy.allow] }
|
|
661
|
+
: {
|
|
662
|
+
allow: Object.fromEntries(
|
|
663
|
+
Object.entries(policy.allow).map(([host, rules]) => [
|
|
664
|
+
host,
|
|
665
|
+
rules.map(rule => ({
|
|
666
|
+
...(rule.match == null
|
|
667
|
+
? {}
|
|
668
|
+
: { match: cloneMatch(rule.match) }),
|
|
669
|
+
...(rule.transform == null
|
|
670
|
+
? {}
|
|
671
|
+
: {
|
|
672
|
+
transform: rule.transform.map(transform => ({
|
|
673
|
+
...(transform.headers == null
|
|
674
|
+
? {}
|
|
675
|
+
: { headers: { ...transform.headers } }),
|
|
676
|
+
})),
|
|
677
|
+
}),
|
|
678
|
+
...(rule.forwardURL == null
|
|
679
|
+
? {}
|
|
680
|
+
: { forwardURL: rule.forwardURL }),
|
|
681
|
+
})),
|
|
682
|
+
]),
|
|
683
|
+
),
|
|
684
|
+
}),
|
|
685
|
+
...(policy.subnets == null
|
|
686
|
+
? {}
|
|
687
|
+
: {
|
|
688
|
+
subnets: {
|
|
689
|
+
...(policy.subnets.allow == null
|
|
690
|
+
? {}
|
|
691
|
+
: { allow: [...policy.subnets.allow] }),
|
|
692
|
+
...(policy.subnets.deny == null
|
|
693
|
+
? {}
|
|
694
|
+
: { deny: [...policy.subnets.deny] }),
|
|
695
|
+
},
|
|
696
|
+
}),
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function cloneMatch(
|
|
701
|
+
match: NonNullable<NetworkPolicyRule['match']>,
|
|
702
|
+
): NonNullable<NetworkPolicyRule['match']> {
|
|
703
|
+
return {
|
|
704
|
+
...(match.path == null ? {} : { path: cloneMatcher(match.path) }),
|
|
705
|
+
...(match.method == null ? {} : { method: [...match.method] }),
|
|
706
|
+
...(match.queryString == null
|
|
707
|
+
? {}
|
|
708
|
+
: { queryString: match.queryString.map(cloneKeyValueMatcher) }),
|
|
709
|
+
...(match.headers == null
|
|
710
|
+
? {}
|
|
711
|
+
: { headers: match.headers.map(cloneKeyValueMatcher) }),
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function cloneKeyValueMatcher(
|
|
716
|
+
matcher: NetworkPolicyKeyValueMatcher,
|
|
717
|
+
): NetworkPolicyKeyValueMatcher {
|
|
718
|
+
return {
|
|
719
|
+
...(matcher.key == null ? {} : { key: cloneMatcher(matcher.key) }),
|
|
720
|
+
...(matcher.value == null ? {} : { value: cloneMatcher(matcher.value) }),
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function cloneMatcher(matcher: NetworkPolicyMatcher): NetworkPolicyMatcher {
|
|
725
|
+
return { ...matcher };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function cloneHarnessKeyValueMatcher(
|
|
729
|
+
matcher: NonNullable<
|
|
730
|
+
HarnessV1RequestTransformation['match']['headers']
|
|
731
|
+
>[number],
|
|
732
|
+
): NonNullable<HarnessV1RequestTransformation['match']['headers']>[number] {
|
|
733
|
+
return {
|
|
734
|
+
...(matcher.key == null ? {} : { key: cloneHarnessMatcher(matcher.key) }),
|
|
735
|
+
...(matcher.value == null
|
|
736
|
+
? {}
|
|
737
|
+
: { value: cloneHarnessMatcher(matcher.value) }),
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function cloneHarnessMatcher(
|
|
742
|
+
matcher: NonNullable<HarnessV1RequestTransformation['match']['path']>,
|
|
743
|
+
): NonNullable<HarnessV1RequestTransformation['match']['path']> {
|
|
744
|
+
if ('exact' in matcher) {
|
|
745
|
+
return { exact: matcher.exact };
|
|
746
|
+
}
|
|
747
|
+
if ('startsWith' in matcher) {
|
|
748
|
+
return { startsWith: matcher.startsWith };
|
|
749
|
+
}
|
|
750
|
+
return { regex: matcher.regex };
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function createPolicyConflictError(
|
|
754
|
+
message: string,
|
|
755
|
+
): HarnessCapabilityUnsupportedError {
|
|
756
|
+
return new HarnessCapabilityUnsupportedError({
|
|
757
|
+
harnessId: VERCEL_PROVIDER_ID,
|
|
758
|
+
message,
|
|
759
|
+
});
|
|
760
|
+
}
|