Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
84 changes: 84 additions & 0 deletions src/lib/server/mock.js
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,28 @@ const wafZone = {
priority: 30
}
],
limits: [
{
id: 'per-ip',
description: 'Baseline per-client limit',
key: ['ip'],
rate: 100,
window: '1m',
algorithm: 'sliding',
mode: 'enforce',
status: 429,
message: 'Too Many Requests'
},
{
id: 'login-burst',
description: 'Protect login from credential stuffing',
key: ['ip', 'header:x-forwarded-user'],
rate: 10,
window: '10s',
algorithm: 'fixed',
mode: 'shadow'
}
],
status: 'success',
action: 'create',
createdAt: CREATED_AT,
Expand Down Expand Up @@ -337,6 +359,62 @@ function wafMetrics (timeRange) {
return { series, total }
}

// Bucket widths per range, matching waf.metrics / waf.limitMetrics server-side.
/** @type {Record<string, number>} */
const wafBucketSeconds = { '1h': 60, '6h': 300, '12h': 600, '1d': 1200, '7d': 3600, '30d': 14400 }

// Synthetic rate-limit metrics for the seed zone's two limits, so the
// "Rate limit activity" section has a trend to draw. Each limit gets an
// `allowed` series with large counts and a `limited` series with small counts,
// tuned so the limited share lands around the limit's target with some
// time variation across the window.
/** @param {string} [timeRange] */
function wafLimitMetrics (timeRange) {
const now = Math.floor(Date.now() / 1000)
const range = timeRange ?? '1d'
const windowSeconds = wafRangeSeconds[range] ?? wafRangeSeconds['1d']
const bucket = wafBucketSeconds[range] ?? wafBucketSeconds['1d']
const from = now - windowSeconds

/**
* @param {string} limitId
* @param {number} base typical allowed count per bucket
* @param {number} share typical limited share (0..1)
*/
const makeLimit = (limitId, base, share) => {
/** @type {[number, number][]} */
const allowed = []
/** @type {[number, number][]} */
const limited = []
let allowedTotal = 0
let limitedTotal = 0
for (let ts = from + bucket; ts <= now; ts += bucket) {
const a = Math.round(base * (0.7 + 0.6 * Math.random()))
allowed.push([ts, a])
allowedTotal += a
// Drift the share over the window (sinus + noise) so the trend line
// actually trends; sparse — calm buckets emit no `limited` point.
const wave = 0.6 + 0.4 * Math.sin(((ts - from) / windowSeconds) * Math.PI * 5) + 0.4 * Math.random()
const l = Math.round(a * share * wave)
if (l > 0) {
limited.push([ts, l])
limitedTotal += l
}
}
return [
{ limitId, result: 'allowed', total: allowedTotal, points: allowed },
{ limitId, result: 'limited', total: limitedTotal, points: limited }
]
}

const series = [
...makeLimit('per-ip', 450, 0.018), // share ~0.5–3%
...makeLimit('login-burst', 80, 0.07) // share ~4–10%
]
const total = series.reduce((acc, s) => acc + s.total, 0)
return { series, total }
}

// Locations (besides the seed LOCATION_ID) that have had a firewall created in
// this dev session, mapped to { description, polls }. `polls` counts how many
// times the zone has been read while pending; the deployer is simulated by
Expand Down Expand Up @@ -366,6 +444,7 @@ function wafConfiguredZone (location, advance = false) {
location,
description: entry.description,
rules: [],
limits: [],
status,
action: 'create',
createdAt: CREATED_AT,
Expand Down Expand Up @@ -842,6 +921,11 @@ const handlers = {
if (location === LOCATION_ID) return ok(wafMetrics(args?.timeRange))
return ok({ series: [], total: 0 })
},
'waf.limitMetrics': (args) => {
const location = args?.location ?? LOCATION_ID
if (location === LOCATION_ID) return ok(wafLimitMetrics(args?.timeRange))
return ok({ series: [], total: 0 })
},
'waf.delete': (args) => {
if (args?.location) wafConfigured.delete(args.location)
return ok({})
Expand Down
182 changes: 182 additions & 0 deletions src/lib/waf/limits.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// Shared rate-limit helpers for the firewall manage + limit edit pages. Limit
// ids are auto-generated and stable for the life of a limit (like rule ids),
// but limits have no priority — order doesn't matter.

/**
* One characteristic of the bucket key. Header/cookie carry a name; the rest
* round-trip as a bare keyword ('ip', 'host', 'country', 'asn').
* @typedef {Object} KeyRow
* @property {'ip' | 'host' | 'country' | 'asn' | 'header' | 'cookie'} type
* @property {string} name
*/

/**
* @typedef {Object} LimitForm
* @property {string} id
* @property {string} description
* @property {KeyRow[]} key
* @property {number} rate
* @property {string} window
* @property {'fixed' | 'sliding'} algorithm
* @property {'enforce' | 'shadow'} mode
* @property {number} status
* @property {string} message
*/

export const DEFAULT_LIMIT_STATUS = 429
export const DEFAULT_LIMIT_MESSAGE = 'Too Many Requests'

/** @type {Record<string, string>} */
export const modeLabels = {
enforce: 'Enforce',
shadow: 'Shadow'
}

/** @type {Record<KeyRow['type'], string>} */
export const keyTypeLabels = {
ip: 'IP address',
host: 'Host',
country: 'Country',
asn: 'ASN',
header: 'Header',
cookie: 'Cookie'
}

// Window presets covering the API's allowed 1s..1h range. Zones loaded with a
// window outside this list still render — the edit page appends the loaded
// value as an extra option.
/** @type {{ value: string, label: string }[]} */
export const windowOptions = [
{ value: '1s', label: '1 second' },
{ value: '10s', label: '10 seconds' },
{ value: '30s', label: '30 seconds' },
{ value: '1m', label: '1 minute' },
{ value: '5m', label: '5 minutes' },
{ value: '10m', label: '10 minutes' },
{ value: '30m', label: '30 minutes' },
{ value: '1h', label: '1 hour' }
]

/**
* Parse an API key part ('ip', 'header:x-api-key', …) into a form row.
* @param {string} part
* @returns {KeyRow}
*/
export function parseKeyPart (part) {
if (part.startsWith('header:')) return { type: 'header', name: part.slice('header:'.length) }
if (part.startsWith('cookie:')) return { type: 'cookie', name: part.slice('cookie:'.length) }
if (part === 'host' || part === 'country' || part === 'asn') return { type: part, name: '' }
return { type: 'ip', name: '' }
}

/**
* Render a form row back into the API key part shape. Header/cookie rows with
* an empty name are incomplete and yield ''. Header names are normalized to
* lowercase (HTTP headers are case-insensitive — parapet does the same);
* cookie names keep their spelling (cookies match case-sensitively).
* @param {KeyRow} row
* @returns {string}
*/
export function keyRowToApi (row) {
if (row.type === 'header' || row.type === 'cookie') {
const name = row.name.trim()
if (!name) return ''
return row.type === 'header' ? `header:${name.toLowerCase()}` : `cookie:${name}`
}
return row.type
}

/**
* Human label for an API key, e.g. ['ip', 'header:x-api-key'] →
* "IP + Header x-api-key".
* @param {string[]} [key]
* @returns {string}
*/
export function describeKey (key) {
const parts = (key && key.length > 0 ? key : ['ip']).map((part) => {
const row = parseKeyPart(part)
if (row.type === 'header' || row.type === 'cookie') {
return `${keyTypeLabels[row.type]} ${row.name}`
}
return row.type === 'ip' ? 'IP' : keyTypeLabels[row.type]
})
return parts.join(' + ')
}

/**
* @param {Api.WafLimit} [limit]
* @returns {LimitForm}
*/
export function limitForm (limit) {
return {
id: limit?.id ?? '',
description: limit?.description ?? '',
key: (limit?.key?.length ? limit.key : ['ip']).map(parseKeyPart),
rate: limit?.rate ?? 100,
window: limit?.window ?? '1m',
algorithm: limit?.algorithm ?? 'fixed',
mode: limit?.mode ?? 'enforce',
status: limit?.status ?? DEFAULT_LIMIT_STATUS,
message: limit?.message ?? DEFAULT_LIMIT_MESSAGE
}
}

/**
* Generate a stable, unique limit id that doesn't collide with `taken`.
* @param {string[]} taken
* @returns {string}
*/
export function genLimitId (taken) {
let id
do {
id = 'limit-' + Math.random().toString(36).slice(2, 8)
} while (taken.includes(id))
return id
}

/**
* Map API limits to form rows, giving every row a unique id.
* @param {Api.WafLimit[]} [apiLimits]
* @returns {LimitForm[]}
*/
export function normalizeLimits (apiLimits) {
/** @type {string[]} */
const taken = []
return (apiLimits ?? []).map((l) => {
const f = limitForm(l)
if (!f.id || taken.includes(f.id)) f.id = genLimitId(taken)
taken.push(f.id)
return f
})
}

/**
* Map form rows back to the API limit shape. Key rows are deduped (and
* incomplete header/cookie rows dropped), falling back to ['ip'] when nothing
* valid remains.
* @param {LimitForm[]} limits
* @returns {Api.WafLimit[]}
*/
export function toApiLimits (limits) {
return limits.map((l) => {
/** @type {string[]} */
const key = []
for (const row of l.key) {
const part = keyRowToApi(row)
if (part && !key.includes(part)) key.push(part)
}
if (key.length === 0) key.push('ip')

return {
id: l.id,
description: l.description,
key,
rate: Number(l.rate) || 1,
window: l.window,
algorithm: l.algorithm === 'sliding' ? 'sliding' : 'fixed',
mode: l.mode === 'shadow' ? 'shadow' : 'enforce',
status: Number(l.status) === 503 ? 503 : DEFAULT_LIMIT_STATUS,
message: l.message || DEFAULT_LIMIT_MESSAGE
}
})
}
6 changes: 4 additions & 2 deletions src/routes/(auth)/(project)/waf/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
<th>Status</th>
<th>Description</th>
<th>Rules</th>
<th>Limits</th>
<th>Matches (24h)</th>
<th class="is-collapse is-align-right"></th>
</tr>
Expand Down Expand Up @@ -133,6 +134,7 @@
{/if}
</td>
<td>{fw.rules?.length ?? 0}</td>
<td>{fw.limits?.length ?? 0}</td>
<td>
<!-- Reserve the loaded size in every state so the row/column keeps
its dimensions while the async sparkline fills in (no layout shift). -->
Expand Down Expand Up @@ -164,14 +166,14 @@
</tr>
{/each}
<NoDataRow
span={6}
span={7}
list={firewalls}
icon="fa-shield-halved"
message="No firewalls yet"
hint="Create a firewall to start filtering traffic in a location."
ctaLabel="Create firewall"
ctaHref={`/waf/create?project=${project}`} />
<ErrorRow span={6} {error} />
<ErrorRow span={7} {error} />
</tbody>
</table>
</div>
Expand Down
3 changes: 2 additions & 1 deletion src/routes/(auth)/(project)/waf/create/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
project,
location: form.location,
description: form.description,
rules: []
rules: [],
limits: []
}, fetch)
if (!resp.ok) {
modal.error({ error: resp.error })
Expand Down
7 changes: 6 additions & 1 deletion src/routes/(auth)/(project)/waf/edit/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
normalizeRules,
toApiRules
} from '$lib/waf/rules'
import { normalizeLimits, toApiLimits } from '$lib/waf/limits'

const { data } = $props()

Expand All @@ -29,6 +30,9 @@
// The whole loaded zone's rules (ordered) are held in memory so Save can
// rewrite the entire zone with the edited rule in place.
const rules = untrack(() => normalizeRules(data.zone?.rules))
// waf.set replaces the whole zone, so the zone's limits must be echoed back
// untouched — otherwise saving a rule would wipe them.
const limits = untrack(() => normalizeLimits(data.zone?.limits))
const description = untrack(() => data.zone?.description ?? '')
// Index of the rule being edited, or -1 when adding a brand-new rule.
const editIndex = untrack(() => (data.ruleId ? rules.findIndex((r) => r.id === data.ruleId) : -1))
Expand Down Expand Up @@ -102,7 +106,8 @@
project,
location,
description,
rules: toApiRules(nextRules)
rules: toApiRules(nextRules),
limits: toApiLimits(limits)
}, fetch)
if (!resp.ok) {
modal.error({ error: resp.error })
Expand Down
Loading