Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions cluster/deployment/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ monitoring:
cloudArmor:
enabled: false
allRulesPreviewOnly: true
publicEndpoints:
publicScan:
hostname: scan.sv-2.scratcha.global.canton.network.digitalasset.com
pathPrefix: /api/scan
throttleAcrossAllEndpointsAllIps:
withinIntervalSeconds: 60
maxRequestsBeforeHttp429: 0
multiValidator:
postgresPvcSize: '100Gi'
resources:
Expand Down
125 changes: 55 additions & 70 deletions cluster/pulumi/infra/src/cloudArmor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
import * as gcp from '@pulumi/gcp';
import * as pulumi from '@pulumi/pulumi';
import * as _ from 'lodash';
import { CLUSTER_BASENAME } from '@lfdecentralizedtrust/splice-pulumi-common';

import * as config from './config';
Expand All @@ -19,17 +20,12 @@ export interface ApiEndpoint {
hostname: string;
}

export interface ThrottleConfig {
perIp: boolean;
rate: number; // Requests per minute
interval: number; // Interval in seconds
}

export type CloudArmorConfig = Pick<config.CloudArmorConfig, 'enabled' | 'allRulesPreviewOnly'> & {
export type CloudArmorConfig = config.CloudArmorConfig & {
predefinedWafRules?: PredefinedWafRule[];
apiThrottles?: ApiThrottleConfig[];
};

type ThrottleConfig = CloudArmorConfig['publicEndpoints'];

export interface PredefinedWafRule {
name: string;
action: 'allow' | 'deny' | 'throttle';
Expand All @@ -38,14 +34,6 @@ export interface PredefinedWafRule {
sensitivityLevel?: 'off' | 'low' | 'medium' | 'high';
}

// TODO (DACH-NY/canton-network-internal#2115) replace this placeholder config
// with the real yaml structure we want to use
export interface ApiThrottleConfig {
endpoint: ApiEndpoint;
throttle: ThrottleConfig;
action: 'throttle' | 'ban';
}

/**
* Creates a Cloud Armor security policy
* @param cac loaded configuration
Expand Down Expand Up @@ -86,8 +74,8 @@ export function configureCloudArmorPolicy(
addIpWhitelistRules(/*securityPolicy, cac.allRulesPreviewOnly, ruleOpts*/);

// Step 4: Add throttling/banning rules for specific API endpoints
if (cac.apiThrottles && cac.apiThrottles.length > 0) {
addThrottleAndBanRules(securityPolicy, cac.apiThrottles, cac.allRulesPreviewOnly, ruleOpts);
if (cac.publicEndpoints && !_.isEmpty(cac.publicEndpoints)) {
addThrottleAndBanRules(securityPolicy, cac.publicEndpoints, cac.allRulesPreviewOnly, ruleOpts);
}

// Step 5: Add default deny rule
Expand Down Expand Up @@ -126,62 +114,59 @@ function addIpWhitelistRules(): void {
*/
function addThrottleAndBanRules(
securityPolicy: gcp.compute.SecurityPolicy,
apiThrottles: ApiThrottleConfig[],
throttles: ThrottleConfig,
preview: boolean,
opts: pulumi.ResourceOptions
): void {
apiThrottles.reduce((priority, apiConfig) => {
if (priority >= THROTTLE_BAN_RULE_MAX) {
throw new Error(
`Throttle rule priority ${priority} exceeds maximum ${THROTTLE_BAN_RULE_MAX}`
);
}

const { endpoint, throttle, action } = apiConfig;
const ruleName = `${action}${throttle.perIp ? '-per-ip' : ''}-${endpoint.name}`;

// Build the expression for path and hostname matching
const pathExpr = `request.path.matches('${endpoint.path}')`;
const hostExpr = `request.headers['host'].matches('${endpoint.hostname}')`;
const matchExpr = `${pathExpr} && ${hostExpr}`;

new gcp.compute.SecurityPolicyRule(
ruleName,
{
securityPolicy: securityPolicy.name,
description: `${action === 'throttle' ? 'Throttle' : 'Ban'} rule${throttle.perIp ? ' per-IP' : ''} for ${endpoint.name} API endpoint`,
priority,
preview,
action: action === 'ban' ? 'rate_based_ban' : 'throttle',
match: {
expr: {
expression: matchExpr,
_.sortBy(Object.entries(throttles), e => e[0]).reduce(
(priority, [confEntryHead, singleServiceThrottle]) => {
if (priority >= THROTTLE_BAN_RULE_MAX) {
throw new Error(
`Throttle rule priority ${priority} exceeds maximum ${THROTTLE_BAN_RULE_MAX}`
);
}

const { hostname, pathPrefix, throttleAcrossAllEndpointsAllIps } = singleServiceThrottle;
// leave out the rule but consume the priority number if max is 0
// this makes the pulumi update cleaner if toggling just one service
if (throttleAcrossAllEndpointsAllIps.maxRequestsBeforeHttp429 > 0) {
const ruleName = `throttle-all-endpoints-all-ips-${confEntryHead}`;

// Build the expression for path and hostname matching
const pathExpr = `request.path.startsWith(R"${pathPrefix}")`;
const hostExpr = `request.headers['host'].matches(R"^${_.escapeRegExp(hostname)}(:[0-9]+)?$")`;
const matchExpr = `${pathExpr} && ${hostExpr}`;

new gcp.compute.SecurityPolicyRule(
ruleName,
{
securityPolicy: securityPolicy.name,
description: `Throttle rule for all ${confEntryHead} API endpoints`,
priority,
preview: preview || singleServiceThrottle.rulePreviewOnly,
action: 'throttle',
match: {
expr: {
expression: matchExpr,
},
},
rateLimitOptions: {
enforceOnKey: 'ALL',
rateLimitThreshold: {
count: throttleAcrossAllEndpointsAllIps.maxRequestsBeforeHttp429,
intervalSec: throttleAcrossAllEndpointsAllIps.withinIntervalSeconds,
},
conformAction: 'allow',
exceedAction: 'deny(429)', // 429 Too Many Requests
},
},
},
rateLimitOptions: {
// ban point is banThreshold + ratelimit count; consider splitting up rather than doubling
...(action === 'ban'
? {
banDurationSec: 600,
banThreshold: {
count: throttle.rate,
intervalSec: throttle.interval,
},
}
: {}),
enforceOnKey: throttle.perIp ? 'IP' : 'ALL',
rateLimitThreshold: {
count: throttle.rate,
intervalSec: throttle.interval,
},
conformAction: 'allow',
exceedAction: 'deny(429)', // 429 Too Many Requests
},
},
opts
);
return priority + RULE_SPACING;
}, THROTTLE_BAN_RULE_MIN);
opts
);
}
return priority + RULE_SPACING;
},
THROTTLE_BAN_RULE_MIN
);
}

/**
Expand Down
11 changes: 9 additions & 2 deletions cluster/pulumi/infra/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,15 @@ const CloudArmorConfigSchema = z.object({
.object({})
.catchall(
z.object({
domain: z.string(),
// TODO (DACH-NY/canton-network-internal#2115) more config
rulePreviewOnly: z.boolean().default(false),
hostname: z.string().regex(/^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*$/, 'valid DNS hostname'),
pathPrefix: z.string().regex(/^\/[^"]*$/, 'HTTP request path starting with /'),
throttleAcrossAllEndpointsAllIps: z.object({
withinIntervalSeconds: z.number().positive(),
maxRequestsBeforeHttp429: z
.number()
.min(0, '0 to disallow requests or positive to allow'),
}),
})
)
.default({}),
Expand Down