Skip to content

Commit 241e95d

Browse files
authored
feat(policy): support host wildcards and multi-port endpoints (#366)
* feat(policy): support host wildcards and multi-port endpoints Add glob-style host wildcards to endpoints[].host using OPA's glob.match with "." as delimiter — *.example.com matches a single DNS label, **.example.com matches across labels. Validation rejects bare * and requires *. prefix; warns on broad patterns like *.com. Add repeated uint32 ports field to NetworkEndpoint for multi-port support. Backwards compatible: existing port scalar is normalized to ports array. Both the proto-to-JSON and YAML-to-JSON conversion paths emit a ports array; Rego always references endpoint.ports[_]. Fix OpaEngine::reload() to route through the full preprocessing pipeline instead of bypassing L7 validation and port normalization. Closes #359 * fix(policy): reject configs with both port and ports set --------- Co-authored-by: johntmyers <johntmyers@users.noreply.github.com>
1 parent 085b131 commit 241e95d

File tree

8 files changed

+1423
-71
lines changed

8 files changed

+1423
-71
lines changed

architecture/security-policy.md

Lines changed: 163 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,7 @@ network_policies:
387387
name: claude_code # <-- human-readable name (used in audit logs)
388388
endpoints: # <-- allowed host:port pairs
389389
- { host: api.anthropic.com, port: 443 }
390+
- { host: "*.anthropic.com", ports: [443, 8443] } # glob host + multi-port
390391
binaries: # <-- allowed binary identities
391392
- { path: /usr/local/bin/claude }
392393
```
@@ -403,10 +404,11 @@ network_policies:
403404

404405
Each endpoint defines a network destination and, optionally, L7 inspection behavior.
405406

406-
| Field | Type | Default | Description |
407-
| ------------- | ---------- | --------------- | ------------------------------------------------------------------------------------------------------------------- |
408-
| `host` | `string` | _(required)_ | Hostname to match (case-insensitive). Optional when `allowed_ips` is set (see [Hostless Endpoints](#hostless-endpoints-allowed_ips-without-host)). |
409-
| `port` | `integer` | _(required)_ | TCP port to match |
407+
| Field | Type | Default | Description |
408+
| ------------- | ----------- | --------------- | ------------------------------------------------------------------------------------------------------------------- |
409+
| `host` | `string` | _(required)_ | Hostname or glob pattern to match (case-insensitive). Supports wildcards (`*.example.com`). Optional when `allowed_ips` is set (see [Hostless Endpoints](#hostless-endpoints-allowed_ips-without-host)). See [Host Wildcards](#host-wildcards). |
410+
| `port` | `integer` | _(required)_ | TCP port to match. Mutually exclusive with `ports` — if both are set, `ports` takes precedence. See [Multi-Port Endpoints](#multi-port-endpoints). |
411+
| `ports` | `integer[]`| `[]` | Multiple TCP ports to match. When non-empty, the endpoint covers all listed ports. Backwards compatible with `port`. See [Multi-Port Endpoints](#multi-port-endpoints). |
410412
| `protocol` | `string` | `""` | Application protocol for L7 inspection. See [Behavioral Trigger: L7 Inspection](#behavioral-trigger-l7-inspection). |
411413
| `tls` | `string` | `"passthrough"` | TLS handling mode. See [Behavioral Trigger: TLS Termination](#behavioral-trigger-tls-termination). |
412414
| `enforcement` | `string` | `"audit"` | L7 enforcement mode: `"enforce"` or `"audit"` |
@@ -463,6 +465,135 @@ The `access` field provides shorthand for common rule sets. During preprocessing
463465

464466
See `crates/openshell-sandbox/src/l7/mod.rs` -- `expand_access_presets()`.
465467

468+
#### Host Wildcards
469+
470+
The `host` field supports glob patterns for matching multiple subdomains under a common domain. Wildcards use OPA's `glob.match` function with `.` as the delimiter, consistent with TLS certificate wildcard semantics.
471+
472+
| Pattern | Matches | Does Not Match |
473+
|---------|---------|----------------|
474+
| `*.example.com` | `api.example.com`, `cdn.example.com` | `example.com`, `deep.sub.example.com` |
475+
| `**.example.com` | `api.example.com`, `deep.sub.example.com` | `example.com` |
476+
| `*.EXAMPLE.COM` | `api.example.com` (case-insensitive) | |
477+
478+
**Wildcard semantics**:
479+
480+
- `*` matches exactly one DNS label (does not cross `.` boundaries). `*.example.com` matches `api.example.com` but not `deep.sub.example.com`.
481+
- `**` matches across label boundaries. `**.example.com` matches both `api.example.com` and `deep.sub.example.com`.
482+
- Matching is case-insensitive — both the pattern and the incoming hostname are lowercased before comparison.
483+
- The bare domain is never matched. `*.example.com` does not match `example.com` (there must be at least one label before the domain).
484+
485+
**Validation rules**:
486+
487+
- **Error**: Bare `*` or `**` (matches all hosts) is rejected. Use a specific pattern like `*.example.com`.
488+
- **Error**: Patterns must start with `*.` or `**.` prefix. Malformed patterns like `*com` are rejected.
489+
- **Warning**: Broad patterns like `*.com` (only two labels) trigger a warning about covering all subdomains of a TLD.
490+
491+
See `crates/openshell-sandbox/src/l7/mod.rs` -- `validate_l7_policies()` for validation, `sandbox-policy.rego` -- `endpoint_allowed` for the Rego glob matching rule.
492+
493+
**Rego implementation**: The Rego rules detect host wildcards via `contains(endpoint.host, "*")` and dispatch to `glob.match(lower(endpoint.host), ["."], lower(network.host))`. Exact-match hosts use a separate, faster `lower(endpoint.host) == lower(network.host)` rule. See `crates/openshell-sandbox/data/sandbox-policy.rego`.
494+
495+
**Example**: Allow any subdomain of `example.com` on port 443:
496+
497+
```yaml
498+
network_policies:
499+
example_wildcard:
500+
name: example_wildcard
501+
endpoints:
502+
- host: "*.example.com"
503+
port: 443
504+
binaries:
505+
- { path: /usr/bin/curl }
506+
```
507+
508+
Host wildcards compose with all other endpoint features — L7 inspection, TLS termination, multi-port, and `allowed_ips`:
509+
510+
```yaml
511+
network_policies:
512+
wildcard_l7:
513+
name: wildcard_l7
514+
endpoints:
515+
- host: "*.example.com"
516+
port: 8080
517+
protocol: rest
518+
tls: terminate
519+
enforcement: enforce
520+
rules:
521+
- allow:
522+
method: GET
523+
path: "/api/**"
524+
binaries:
525+
- { path: /usr/bin/curl }
526+
```
527+
528+
#### Multi-Port Endpoints
529+
530+
The `ports` field allows a single endpoint entry to cover multiple TCP ports. This avoids duplicating endpoint definitions that differ only in port number.
531+
532+
**Normalization**: Both YAML loading paths (file mode and gRPC mode) normalize `port` and `ports` before the data reaches the OPA engine:
533+
534+
- If `ports` is non-empty, it takes precedence. `port` is ignored.
535+
- If `ports` is empty and `port` is set, the scalar is promoted to `ports: [port]`.
536+
- The scalar `port` field is removed from the JSON fed to OPA. Rego rules always reference `endpoint.ports[_]`.
537+
538+
This normalization happens in `crates/openshell-sandbox/src/opa.rs` -- `normalize_endpoint_ports()` (YAML path) and `proto_to_opa_data_json()` (proto path).
539+
540+
**Backwards compatibility**: Existing policies using `port: 443` continue to work without changes. The scalar is silently promoted to `ports: [443]` at load time.
541+
542+
**YAML serialization**: When serializing policy back to YAML (e.g., `nav policy get --full`), a single-element `ports` array is emitted as the compact `port: N` scalar form. Multi-element arrays are emitted as `ports: [N, M]`. See `crates/openshell-policy/src/lib.rs` -- `from_proto()`.
543+
544+
**Example**: Allow both standard HTTPS and a custom TLS port:
545+
546+
```yaml
547+
network_policies:
548+
multi_port:
549+
name: multi_port
550+
endpoints:
551+
- host: api.example.com
552+
ports:
553+
- 443
554+
- 8443
555+
binaries:
556+
- { path: /usr/bin/curl }
557+
```
558+
559+
This is equivalent to two separate endpoint entries:
560+
561+
```yaml
562+
endpoints:
563+
- { host: api.example.com, port: 443 }
564+
- { host: api.example.com, port: 8443 }
565+
```
566+
567+
Multi-port endpoints compose with host wildcards, L7 rules, and all other endpoint fields:
568+
569+
```yaml
570+
network_policies:
571+
wildcard_multi_port:
572+
name: wildcard_multi_port
573+
endpoints:
574+
- host: "*.example.com"
575+
ports: [443, 8443]
576+
protocol: rest
577+
tls: terminate
578+
enforcement: enforce
579+
access: read-only
580+
binaries:
581+
- { path: /usr/bin/curl }
582+
```
583+
584+
Hostless endpoints also support multi-port:
585+
586+
```yaml
587+
network_policies:
588+
private_multi:
589+
name: private_multi
590+
endpoints:
591+
- ports: [80, 443]
592+
allowed_ips: ["10.0.0.0/8"]
593+
binaries:
594+
- { path: /usr/bin/curl }
595+
```
596+
466597
---
467598

468599
### Inference Routing
@@ -793,6 +924,8 @@ The following validation rules are enforced during policy loading (both file mod
793924
| `tls: terminate` without `protocol` | `TLS termination requires a protocol for L7 inspection` |
794925
| `protocol: sql` with `enforcement: enforce` | `SQL enforcement requires full SQL parsing (not available in v1). Use enforcement: audit.` |
795926
| `rules: []` (empty list) | `rules list cannot be empty (would deny all traffic). Use access: full or remove rules.` |
927+
| Host wildcard is bare `*` or `**` | `host wildcard '*' matches all hosts; use specific patterns like '*.example.com'` |
928+
| Host wildcard does not start with `*.` or `**.`| `host wildcard must start with '*.' or '**.' (e.g., '*.example.com'), got '{host}'` |
796929
| Invalid HTTP method in REST rules | _(warning, not error)_ |
797930

798931
### Errors (Live Update Rejection)
@@ -812,6 +945,7 @@ These errors are returned by the gateway's `UpdateSandboxPolicy` handler and rej
812945
| Condition | Warning Message |
813946
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
814947
| `protocol: rest` on port 443 without `tls: terminate` | `L7 rules won't be evaluated on encrypted traffic without tls: terminate` |
948+
| Host wildcard with ≤2 labels (e.g., `*.com`) | `host wildcard '*.com' is very broad (covers all subdomains of a TLD)` |
815949
| Unknown HTTP method in rules (not GET/HEAD/POST/PUT/DELETE/PATCH/OPTIONS/\*) | `Unknown HTTP method '{method}'. Standard methods: GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS.` |
816950

817951
See `crates/openshell-sandbox/src/l7/mod.rs` -- `validate_l7_policies()`.
@@ -1078,6 +1212,30 @@ network_policies:
10781212
binaries:
10791213
- { path: /usr/bin/curl }
10801214
1215+
# Host wildcard: allow any subdomain of example.com on dual ports
1216+
example_apis:
1217+
name: example_apis
1218+
endpoints:
1219+
- host: "*.example.com"
1220+
ports:
1221+
- 443
1222+
- 8443
1223+
binaries:
1224+
- { path: /usr/bin/curl }
1225+
1226+
# Multi-port with L7: same L7 rules applied across two ports
1227+
multi_port_l7:
1228+
name: multi_port_l7
1229+
endpoints:
1230+
- host: api.internal.svc
1231+
ports: [8080, 9090]
1232+
protocol: rest
1233+
tls: terminate
1234+
enforcement: enforce
1235+
access: read-only
1236+
binaries:
1237+
- { path: /usr/bin/curl }
1238+
10811239
# Forward proxy + CONNECT: private service accessible via plain HTTP or tunnel
10821240
# With allowed_ips set and the destination being a private IP, both
10831241
# `http://10.86.8.223:8000/path` (forward proxy) and
@@ -1115,7 +1273,7 @@ When the gateway delivers policy via gRPC, the protobuf `SandboxPolicy` message
11151273
| `NetworkPolicyRule` | `name` | `network_policies.<key>.name` |
11161274
| `NetworkPolicyRule` | `endpoints` | `network_policies.<key>.endpoints` |
11171275
| `NetworkPolicyRule` | `binaries` | `network_policies.<key>.binaries` |
1118-
| `NetworkEndpoint` | `host`, `port`, `protocol`, `tls`, `enforcement`, `access`, `rules`, `allowed_ips` | Same field names |
1276+
| `NetworkEndpoint` | `host`, `port`, `ports`, `protocol`, `tls`, `enforcement`, `access`, `rules`, `allowed_ips` | Same field names. `port`/`ports` normalized during loading (see [Multi-Port Endpoints](#multi-port-endpoints)). |
11191277
| `L7Rule` | `allow` | `rules[].allow` |
11201278
| `L7Allow` | `method`, `path`, `command` | `rules[].allow.method`, `.path`, `.command` |
11211279

0 commit comments

Comments
 (0)