Skip to content

Commit 25c5df0

Browse files
acoshiftclaude
andcommitted
feat: adapt WAF value control to field/operator
Drive the WAF condition builder's value input from the selected field and operator: a TLS on/off toggle for the scheme field (https/http), a free-text datalist combobox for method equals/not_equals, and a chip-based TagInput for the contains-any/starts-with-any operators. Drop the Body field. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 4cf01e6 commit 25c5df0

3 files changed

Lines changed: 205 additions & 40 deletions

File tree

src/lib/components/TagInput.svelte

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<script>
2+
/**
3+
* @typedef {Object} Props
4+
* @property {string[]} tags bindable list of committed chips
5+
* @property {string} [placeholder] placeholder for the text input
6+
* @property {string} [id] id for the inner input (label association)
7+
*/
8+
9+
/** @type {Props} */
10+
let { tags = $bindable([]), placeholder = '', id } = $props()
11+
12+
let draft = $state('')
13+
14+
/** @type {HTMLInputElement | undefined} */
15+
let inputEl = $state()
16+
17+
// Commit the current draft as a chip: trim, ignore empties, de-dupe.
18+
function commit () {
19+
const v = draft.trim()
20+
draft = ''
21+
if (!v) return
22+
if (tags.includes(v)) return
23+
tags = [...tags, v]
24+
}
25+
26+
/** @param {number} i */
27+
function remove (i) {
28+
tags = tags.filter((_, idx) => idx !== i)
29+
inputEl?.focus()
30+
}
31+
32+
/** @param {KeyboardEvent} e */
33+
function onkeydown (e) {
34+
if (e.key === 'Enter') {
35+
e.preventDefault()
36+
commit()
37+
} else if (e.key === 'Backspace' && draft === '' && tags.length > 0) {
38+
// Backspace on an empty input removes the last chip.
39+
e.preventDefault()
40+
tags = tags.slice(0, -1)
41+
}
42+
}
43+
</script>
44+
45+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
46+
<div class="tag-input input" onclick={() => inputEl?.focus()}>
47+
{#each tags as tag, i (tag)}
48+
<span class="chip">
49+
<span class="chip-label">{tag}</span>
50+
<button type="button" class="chip-remove" aria-label={`Remove ${tag}`} onclick={(e) => { e.stopPropagation(); remove(i) }}>
51+
<i class="fa-solid fa-xmark"></i>
52+
</button>
53+
</span>
54+
{/each}
55+
<input
56+
bind:this={inputEl}
57+
{id}
58+
class="chip-field"
59+
bind:value={draft}
60+
placeholder={tags.length === 0 ? placeholder : ''}
61+
{onkeydown}
62+
onblur={commit}>
63+
</div>
64+
65+
<style>
66+
.tag-input {
67+
display: flex;
68+
flex-wrap: wrap;
69+
align-items: center;
70+
gap: 0.375rem;
71+
padding: 0.375rem 0.5rem;
72+
min-height: 2.5rem;
73+
cursor: text;
74+
}
75+
76+
.chip {
77+
display: inline-flex;
78+
align-items: center;
79+
gap: 0.35rem;
80+
padding: 0.125rem 0.25rem 0.125rem 0.5rem;
81+
border-radius: 9999px;
82+
font-size: 0.8125rem;
83+
line-height: 1.4;
84+
background-color: hsl(var(--hsl-base-400) / 0.45);
85+
color: hsl(var(--hsl-content));
86+
}
87+
88+
:root:not(.dark) .chip {
89+
background-color: hsl(var(--hsl-base-400) / 0.25);
90+
}
91+
92+
.chip-label {
93+
white-space: nowrap;
94+
}
95+
96+
.chip-remove {
97+
display: inline-flex;
98+
align-items: center;
99+
justify-content: center;
100+
width: 1.125rem;
101+
height: 1.125rem;
102+
border-radius: 9999px;
103+
font-size: 0.6875rem;
104+
color: hsl(var(--hsl-content) / 0.6);
105+
cursor: pointer;
106+
transition: background-color var(--timing-faster) ease, color var(--timing-faster) ease;
107+
}
108+
109+
.chip-remove:hover {
110+
background-color: hsl(var(--hsl-content) / 0.12);
111+
color: hsl(var(--hsl-content));
112+
}
113+
114+
.chip-field {
115+
flex: 1;
116+
min-width: 6rem;
117+
padding: 0.25rem 0.25rem;
118+
background: transparent;
119+
border: 0;
120+
outline: 0;
121+
color: inherit;
122+
font: inherit;
123+
font-size: 0.875rem;
124+
}
125+
</style>

src/lib/components/WafConditionBuilder.svelte

Lines changed: 70 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script>
22
import Select from '$lib/components/Select.svelte'
3+
import TagInput from '$lib/components/TagInput.svelte'
34
import {
45
fields,
56
getField,
@@ -23,14 +24,21 @@
2324
let name = $state('')
2425
let operator = $state('equals')
2526
let value = $state('')
26-
let values = $state('')
27+
/** @type {string[]} */
28+
let valuesList = $state([])
29+
let tls = $state(true)
2730
let combine = $state(/** @type {'and' | 'or' | 'replace'} */ ('and'))
2831
2932
const fieldMeta = $derived(getField(field))
3033
const fieldType = $derived(fieldMeta?.type ?? 'string')
3134
const needsName = $derived(!!fieldMeta?.hasName)
3235
const operators = $derived(operatorsForType(fieldType))
3336
const multi = $derived(isMultiOperator(operator))
37+
const isTls = $derived(fieldType === 'tls')
38+
// Free-text combobox (datalist) for equals/not_equals on suggestion-backed fields.
39+
const useCombobox = $derived(
40+
!!fieldMeta?.suggestions && (operator === 'equals' || operator === 'not_equals')
41+
)
3442
3543
const fieldOptions = fields.map((f) => ({ value: f.value, label: f.label }))
3644
const operatorOptions = $derived(operators.map((o) => ({ value: o.value, label: o.label })))
@@ -45,16 +53,22 @@
4553
name,
4654
operator,
4755
value,
48-
values
56+
// `buildExpression`/`parseList` consume the raw multi-value text contract;
57+
// feed the chips joined by newlines so the output format is unchanged.
58+
values: valuesList.join('\n'),
59+
tls
4960
})
5061
5162
// Live preview of the single condition the builder controls describe.
5263
const preview = $derived(buildExpression(spec))
53-
const canAdd = $derived(preview !== '')
64+
// A TLS condition is always complete; otherwise rely on a non-empty preview.
65+
const canAdd = $derived(isTls || preview !== '')
5466
5567
// Keep the operator valid when the field type changes (e.g. switching from a
5668
// string field to Remote IP must not leave "contains any of" selected).
69+
// Guard against an empty operator list (the `'tls'` field has none).
5770
$effect(() => {
71+
if (operators.length === 0) return
5872
const valid = operators.some((o) => o.value === operator)
5973
if (!valid) operator = operators[0]?.value ?? ''
6074
})
@@ -64,7 +78,8 @@
6478
name = ''
6579
operator = 'equals'
6680
value = ''
67-
values = ''
81+
valuesList = []
82+
tls = true
6883
combine = 'and'
6984
}
7085
@@ -98,7 +113,6 @@
98113
<code class="font-mono">.referer</code>,
99114
<code class="font-mono">.remote_ip</code>,
100115
<code class="font-mono">.content_length</code>,
101-
<code class="font-mono">.body</code>,
102116
<code class="font-mono">.headers[…]</code>,
103117
<code class="font-mono">.args[…]</code>,
104118
<code class="font-mono">.cookies[…]</code>.
@@ -140,41 +154,62 @@
140154
{/if}
141155
</div>
142156
143-
<div class="grid gap-4 sm:grid-cols-2">
144-
<div class="field">
145-
<label for="waf-operator">Operator</label>
146-
<Select id="waf-operator" bind:value={operator} options={operatorOptions} />
147-
</div>
148-
</div>
149-
150-
{#if multi}
157+
{#if isTls}
151158
<div class="field">
152-
<label for="waf-values">Values</label>
153-
<div class="textarea">
154-
<textarea id="waf-values" rows="4" bind:value={values}
155-
placeholder="One value per line (commas also accepted)"></textarea>
156-
</div>
159+
<label class="checkbox" for="waf-tls">
160+
<input id="waf-tls" type="checkbox" bind:checked={tls}>
161+
<span>TLS</span>
162+
</label>
163+
<p class="helper">When on, the request must be served over HTTPS.</p>
157164
</div>
158165
{:else}
159-
<div class="field">
160-
<label for="waf-value">
161-
{#if fieldType === 'numeric'}Number
162-
{:else if fieldType === 'ip' && operator === 'in_cidr'}CIDR
163-
{:else if operator === 'matches_regex'}Pattern
164-
{:else}Value{/if}
165-
</label>
166-
<div class="input">
167-
<input id="waf-value" class="font-mono" bind:value
168-
inputmode={fieldType === 'numeric' ? 'numeric' : undefined}
169-
placeholder={fieldType === 'numeric'
170-
? 'e.g. 1048576'
171-
: fieldType === 'ip' && operator === 'in_cidr'
172-
? 'e.g. 10.0.0.0/8'
173-
: operator === 'matches_regex'
174-
? 'e.g. ^/api/v[0-9]+/'
175-
: 'Value'}>
166+
<div class="grid gap-4 sm:grid-cols-2">
167+
<div class="field">
168+
<label for="waf-operator">Operator</label>
169+
<Select id="waf-operator" bind:value={operator} options={operatorOptions} />
176170
</div>
177171
</div>
172+
173+
{#if multi}
174+
<div class="field">
175+
<label for="waf-values">Values</label>
176+
<TagInput id="waf-values" bind:tags={valuesList}
177+
placeholder="Type a value, press Enter to add" />
178+
</div>
179+
{:else if useCombobox}
180+
<div class="field">
181+
<label for="waf-value">Value</label>
182+
<div class="input">
183+
<input id="waf-value" class="font-mono" bind:value
184+
list="waf-value-suggestions" placeholder="e.g. GET">
185+
</div>
186+
<datalist id="waf-value-suggestions">
187+
{#each fieldMeta?.suggestions ?? [] as s (s)}
188+
<option value={s}></option>
189+
{/each}
190+
</datalist>
191+
</div>
192+
{:else}
193+
<div class="field">
194+
<label for="waf-value">
195+
{#if fieldType === 'numeric'}Number
196+
{:else if fieldType === 'ip' && operator === 'in_cidr'}CIDR
197+
{:else if operator === 'matches_regex'}Pattern
198+
{:else}Value{/if}
199+
</label>
200+
<div class="input">
201+
<input id="waf-value" class="font-mono" bind:value
202+
inputmode={fieldType === 'numeric' ? 'numeric' : undefined}
203+
placeholder={fieldType === 'numeric'
204+
? 'e.g. 1048576'
205+
: fieldType === 'ip' && operator === 'in_cidr'
206+
? 'e.g. 10.0.0.0/8'
207+
: operator === 'matches_regex'
208+
? 'e.g. ^/api/v[0-9]+/'
209+
: 'Value'}>
210+
</div>
211+
</div>
212+
{/if}
178213
{/if}
179214
180215
{#if expression.trim()}

src/lib/waf/expression.js

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ function quote (v) {
2727
}
2828

2929
/**
30-
* @typedef {'string' | 'ip' | 'numeric'} FieldType
30+
* @typedef {'string' | 'ip' | 'numeric' | 'tls'} FieldType
3131
*/
3232

3333
/**
@@ -38,21 +38,21 @@ function quote (v) {
3838
* @property {boolean} [hasName] true → field needs an extra name input (header/arg/cookie)
3939
* @property {string} [accessor] fixed CEL accessor (fields without a name)
4040
* @property {string} [accessorMap] CEL map accessor template, `<name>` replaced (fields with a name)
41+
* @property {string[]} [suggestions] free-text combobox suggestions (datalist) for equals/not_equals
4142
*/
4243

4344
/** @type {FieldMeta[]} */
4445
export const fields = [
45-
{ value: 'method', label: 'Method', type: 'string', accessor: 'request.method' },
46+
{ value: 'method', label: 'Method', type: 'string', accessor: 'request.method', suggestions: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] },
4647
{ value: 'path', label: 'Path', type: 'string', accessor: 'request.path' },
4748
{ value: 'host', label: 'Host', type: 'string', accessor: 'request.host' },
4849
{ value: 'query', label: 'Query string', type: 'string', accessor: 'request.query' },
4950
{ value: 'uri', label: 'URI', type: 'string', accessor: 'request.uri' },
50-
{ value: 'scheme', label: 'Scheme', type: 'string', accessor: 'request.scheme' },
51+
{ value: 'scheme', label: 'Scheme', type: 'tls', accessor: 'request.scheme' },
5152
{ value: 'user_agent', label: 'User-Agent', type: 'string', accessor: 'request.user_agent' },
5253
{ value: 'referer', label: 'Referer', type: 'string', accessor: 'request.referer' },
5354
{ value: 'remote_ip', label: 'Remote IP', type: 'ip', accessor: 'request.remote_ip' },
5455
{ value: 'content_length', label: 'Content-Length', type: 'numeric', accessor: 'request.content_length' },
55-
{ value: 'body', label: 'Body', type: 'string', accessor: 'request.body' },
5656
{ value: 'header', label: 'Header', type: 'string', hasName: true, accessorMap: 'request.headers["<name>"]' },
5757
{ value: 'arg', label: 'Query arg', type: 'string', hasName: true, accessorMap: 'request.args["<name>"]' },
5858
{ value: 'cookie', label: 'Cookie', type: 'string', hasName: true, accessorMap: 'request.cookies["<name>"]' }
@@ -117,6 +117,7 @@ export function operatorsForType (type) {
117117
switch (type) {
118118
case 'ip': return ipOperators
119119
case 'numeric': return numericOperators
120+
case 'tls': return []
120121
default: return stringOperators
121122
}
122123
}
@@ -146,6 +147,7 @@ export function parseList (raw) {
146147
* @property {string} operator operator key
147148
* @property {string} [value] single operand (equals/regex/cidr/numeric)
148149
* @property {string} [values] raw multi-value text (any-of operators)
150+
* @property {boolean} [tls] TLS on/off for a `'tls'` field (https vs http)
149151
*/
150152

151153
/**
@@ -183,7 +185,10 @@ export function buildExpression (spec) {
183185
/** @type {string} */
184186
let snippet
185187

186-
if (f.type === 'numeric') {
188+
if (f.type === 'tls') {
189+
// TLS on/off toggle — ignore the operator. ON → https, OFF → http.
190+
snippet = `${accessor} == ${quote(spec.tls === false ? 'http' : 'https')}`
191+
} else if (f.type === 'numeric') {
187192
const op = numericOps[spec.operator]
188193
const raw = (spec.value ?? '').trim()
189194
if (!op || raw === '' || !/^-?\d+$/.test(raw)) return ''

0 commit comments

Comments
 (0)