Skip to content

Commit 1d4ac70

Browse files
authored
fix(policy): keep internal allowed IP proposals pending (#2416)
Signed-off-by: Adrien Langou <alangou@nvidia.com>
1 parent 541b97f commit 1d4ac70

4 files changed

Lines changed: 1014 additions & 126 deletions

File tree

‎architecture/security-policy.md‎

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -122,17 +122,25 @@ through the proposal loop instead of treating the denial as terminal.
122122
policy plus the sandbox's attached-provider credential set, then computes
123123
the delta of findings between the current baseline and the merged policy.
124124
3. **Auto-approval gate (proposer-agnostic, opt-in).** Auto-approval fires
125-
when *both* (a) the prover delta is empty (`prover: no new findings`) AND
126-
(b) the `proposal_approval_mode` setting resolves to `"auto"` — gateway
127-
scope wins, sandbox scope is the per-sandbox override, default is
128-
`"manual"`. When both hold, the gateway internally invokes the approve
129-
path with actor identity `system:auto`. The audit event uses
130-
`CONFIG:APPROVED` and carries `auto=true`, `source=<mode>`,
131-
`prover_delta=empty`, and `resolved_from=<gateway|sandbox>` as unmapped
132-
fields, with message text `"auto-approved: no new prover findings"` —
133-
never `safe`. The opt-in gate preserves OpenShell's default-deny
134-
posture: with no setting at either scope, every proposal lands in
135-
`pending` for human review, even when the prover sees no findings.
125+
only when *all three* conditions hold: (a) `proposal_approval_mode`
126+
resolves to `"auto"` — gateway scope wins, sandbox scope is the
127+
per-sandbox override, default is `"manual"`; (b) the prover delta is empty
128+
(`prover: no new findings`); and (c) the security notes recomputed from
129+
the chunk's current proposed rule are empty (see
130+
[Security-notes gate](#security-notes-gate)). Before merging, the gateway
131+
reloads the stored chunk and reruns both checks on its current rule. This is
132+
important after edits and mechanistic deduplication: the stored rule, not a
133+
duplicate incoming payload or stale persisted analysis, controls the
134+
decision. The recalculated prover verdict is decision-local rather than
135+
persisted, so `validation_result` reads can still show the submit-time
136+
verdict after an edit or deduplication. Decode, prover, or merge failures
137+
leave the chunk pending. The audit event uses `CONFIG:APPROVED` and carries
138+
`auto=true`, `source=<mode>`, `prover_delta=empty`, and
139+
`resolved_from=<gateway|sandbox>` as unmapped fields, with message text
140+
`"auto-approved: no new prover findings"` — never `safe`. The opt-in gate
141+
preserves OpenShell's default-deny posture: with no setting at either
142+
scope, every proposal lands in `pending` for human review, even
143+
when the prover sees no findings.
136144
4. **Implicit supersede.** On any successful submission, the gateway scans
137145
the sandbox's pending chunks for matches on `(host, port, binary)` and
138146
auto-rejects the older ones with reason `"superseded by chunk X"`. This
@@ -152,6 +160,30 @@ through the proposal loop instead of treating the denial as terminal.
152160
policy.
153161
6. **Escalation.** Anything else lands in `pending` for human review.
154162

163+
### Security-notes gate
164+
165+
Separately from the prover, each chunk carries advisory `security_notes`.
166+
Reads, bulk approval, and auto-approval regenerate them from the current
167+
stored proposed rule instead of trusting a persisted value that may be stale
168+
after an edit. Non-empty notes block auto-approval and make
169+
`ApproveAllDraftChunks` skip the chunk unless `include_security_flagged` is
170+
set. The chunk stays `pending`; an explicit human approval can still merge a
171+
flagged chunk.
172+
173+
Private/internal destinations are advisory, not blocking. A literal endpoint
174+
IP, `allowed_ips` entry, or CIDR intersection in RFC 1918, CGNAT
175+
`100.64.0.0/10`, IPv6 ULA `fc00::/7`, or another special-use range covered by
176+
`openshell-core` `net::is_internal_net` produces a note. A hostless rule
177+
carrying `allowed_ips` earns an extra note because it can match any hostname
178+
resolving into the range.
179+
180+
Always-blocked destinations are separate from this advisory classification.
181+
Loopback, link-local, and unspecified IPs/CIDRs, plus `localhost` and known
182+
metadata endpoint hostnames, are excluded from security notes. Submit and edit
183+
may store such a draft, but existing merge validation rejects it when an
184+
approval attempts to add it to policy; runtime SSRF protections remain the
185+
final enforcement boundary.
186+
155187
## What the prover decides
156188

157189
The prover answers four formal questions about each proposed policy

‎crates/openshell-core/src/net.rs‎

Lines changed: 137 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
//! - The mechanistic mapper for proposal filtering
1111
//! - The gateway server for defense-in-depth validation on approval
1212
13+
use ipnet::{IpNet, Ipv4Net, Ipv6Net};
1314
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
1415

1516
/// Check if a hostname is a known cloud metadata hostname that resolves to an
@@ -80,9 +81,9 @@ pub fn is_always_blocked_ip(ip: IpAddr) -> bool {
8081
///
8182
/// Used at policy load time and server-side approval to reject entries that
8283
/// would be silently blocked at runtime by [`is_always_blocked_ip`].
83-
pub fn is_always_blocked_net(net: ipnet::IpNet) -> bool {
84+
pub fn is_always_blocked_net(net: IpNet) -> bool {
8485
match net {
85-
ipnet::IpNet::V4(v4net) => {
86+
IpNet::V4(v4net) => {
8687
let network = v4net.network();
8788
let broadcast = v4net.broadcast();
8889

@@ -107,7 +108,7 @@ pub fn is_always_blocked_net(net: ipnet::IpNet) -> bool {
107108

108109
false
109110
}
110-
ipnet::IpNet::V6(v6net) => {
111+
IpNet::V6(v6net) => {
111112
// For IPv6, check the network address itself and representative
112113
// addresses within the range.
113114
let network = v6net.network();
@@ -155,6 +156,43 @@ pub fn is_always_blocked_net(net: ipnet::IpNet) -> bool {
155156
}
156157
}
157158

159+
const RFC1918_10_NET: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(10, 0, 0, 0), 8);
160+
const RFC1918_172_NET: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(172, 16, 0, 0), 12);
161+
const RFC1918_192_NET: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(192, 168, 0, 0), 16);
162+
const CGNAT_NET: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(100, 64, 0, 0), 10);
163+
const IETF_PROTOCOL_ASSIGNMENTS_NET: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(192, 0, 0, 0), 24);
164+
const TEST_NET_1: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(192, 0, 2, 0), 24);
165+
const BENCHMARKING_NET: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(198, 18, 0, 0), 15);
166+
const TEST_NET_2: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(198, 51, 100, 0), 24);
167+
const TEST_NET_3: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(203, 0, 113, 0), 24);
168+
const LIMITED_BROADCAST_NET: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::BROADCAST, 32);
169+
const IPV6_ULA_NET: Ipv6Net = Ipv6Net::new_assert(Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 0), 7);
170+
171+
const NON_HARD_INTERNAL_V4_NETS: [Ipv4Net; 10] = [
172+
RFC1918_10_NET,
173+
RFC1918_172_NET,
174+
RFC1918_192_NET,
175+
CGNAT_NET,
176+
IETF_PROTOCOL_ASSIGNMENTS_NET,
177+
TEST_NET_1,
178+
BENCHMARKING_NET,
179+
TEST_NET_2,
180+
TEST_NET_3,
181+
LIMITED_BROADCAST_NET,
182+
];
183+
184+
fn ipv4_nets_intersect(left: Ipv4Net, right: Ipv4Net) -> bool {
185+
left.network() <= right.broadcast() && right.network() <= left.broadcast()
186+
}
187+
188+
fn ipv6_nets_intersect(left: Ipv6Net, right: Ipv6Net) -> bool {
189+
left.network() <= right.broadcast() && right.network() <= left.broadcast()
190+
}
191+
192+
fn ipv4_net_to_mapped_ipv6(net: Ipv4Net) -> Ipv6Net {
193+
Ipv6Net::new_assert(net.network().to_ipv6_mapped(), 96_u8 + net.prefix_len())
194+
}
195+
158196
/// Check if an IP address is internal (loopback, private RFC 1918, link-local,
159197
/// or unspecified).
160198
///
@@ -188,6 +226,26 @@ pub fn is_internal_ip(ip: IpAddr) -> bool {
188226
}
189227
}
190228

229+
/// Check if a CIDR network intersects any address range classified by
230+
/// [`is_internal_ip`].
231+
pub fn is_internal_net(net: IpNet) -> bool {
232+
if is_always_blocked_net(net) {
233+
return true;
234+
}
235+
236+
match net {
237+
IpNet::V4(net) => NON_HARD_INTERNAL_V4_NETS
238+
.iter()
239+
.any(|internal| ipv4_nets_intersect(net, *internal)),
240+
IpNet::V6(net) => {
241+
ipv6_nets_intersect(net, IPV6_ULA_NET)
242+
|| NON_HARD_INTERNAL_V4_NETS
243+
.iter()
244+
.any(|internal| ipv6_nets_intersect(net, ipv4_net_to_mapped_ipv6(*internal)))
245+
}
246+
}
247+
}
248+
191249
/// IPv4 internal address check covering RFC 1918, CGNAT (RFC 6598), and other
192250
/// special-use ranges that should never be reachable from sandbox egress.
193251
fn is_internal_v4(v4: Ipv4Addr) -> bool {
@@ -382,90 +440,90 @@ mod tests {
382440

383441
#[test]
384442
fn test_always_blocked_net_loopback_v4() {
385-
let net: ipnet::IpNet = "127.0.0.0/8".parse().unwrap();
443+
let net: IpNet = "127.0.0.0/8".parse().unwrap();
386444
assert!(is_always_blocked_net(net));
387445
}
388446

389447
#[test]
390448
fn test_always_blocked_net_link_local_v4() {
391-
let net: ipnet::IpNet = "169.254.0.0/16".parse().unwrap();
449+
let net: IpNet = "169.254.0.0/16".parse().unwrap();
392450
assert!(is_always_blocked_net(net));
393451
}
394452

395453
#[test]
396454
fn test_always_blocked_net_unspecified_v4() {
397-
let net: ipnet::IpNet = "0.0.0.0/32".parse().unwrap();
455+
let net: IpNet = "0.0.0.0/32".parse().unwrap();
398456
assert!(is_always_blocked_net(net));
399457
}
400458

401459
#[test]
402460
fn test_always_blocked_net_loopback_v6() {
403-
let net: ipnet::IpNet = "::1/128".parse().unwrap();
461+
let net: IpNet = "::1/128".parse().unwrap();
404462
assert!(is_always_blocked_net(net));
405463
}
406464

407465
#[test]
408466
fn test_always_blocked_net_link_local_v6() {
409-
let net: ipnet::IpNet = "fe80::/10".parse().unwrap();
467+
let net: IpNet = "fe80::/10".parse().unwrap();
410468
assert!(is_always_blocked_net(net));
411469
}
412470

413471
#[test]
414472
fn test_always_blocked_net_ipv4_mapped_v6_loopback() {
415-
let net: ipnet::IpNet = "::ffff:127.0.0.1/128".parse().unwrap();
473+
let net: IpNet = "::ffff:127.0.0.1/128".parse().unwrap();
416474
assert!(is_always_blocked_net(net));
417475
}
418476

419477
#[test]
420478
fn test_always_blocked_net_allows_rfc1918() {
421-
let net10: ipnet::IpNet = "10.0.0.0/8".parse().unwrap();
422-
let net172: ipnet::IpNet = "172.16.0.0/12".parse().unwrap();
423-
let net192: ipnet::IpNet = "192.168.0.0/16".parse().unwrap();
479+
let net10: IpNet = "10.0.0.0/8".parse().unwrap();
480+
let net172: IpNet = "172.16.0.0/12".parse().unwrap();
481+
let net192: IpNet = "192.168.0.0/16".parse().unwrap();
424482
assert!(!is_always_blocked_net(net10));
425483
assert!(!is_always_blocked_net(net172));
426484
assert!(!is_always_blocked_net(net192));
427485
}
428486

429487
#[test]
430488
fn test_always_blocked_net_allows_public() {
431-
let net: ipnet::IpNet = "8.8.8.0/24".parse().unwrap();
489+
let net: IpNet = "8.8.8.0/24".parse().unwrap();
432490
assert!(!is_always_blocked_net(net));
433491
}
434492

435493
#[test]
436494
fn test_always_blocked_net_single_ip_loopback() {
437-
let net: ipnet::IpNet = "127.0.0.1/32".parse().unwrap();
495+
let net: IpNet = "127.0.0.1/32".parse().unwrap();
438496
assert!(is_always_blocked_net(net));
439497
}
440498

441499
#[test]
442500
fn test_always_blocked_net_single_ip_metadata() {
443-
let net: ipnet::IpNet = "169.254.169.254/32".parse().unwrap();
501+
let net: IpNet = "169.254.169.254/32".parse().unwrap();
444502
assert!(is_always_blocked_net(net));
445503
}
446504

447505
#[test]
448506
fn test_always_blocked_net_broad_cidr_containing_blocked() {
449507
// 0.0.0.0/0 contains everything including unspecified, loopback, link-local
450-
let net: ipnet::IpNet = "0.0.0.0/0".parse().unwrap();
508+
let net: IpNet = "0.0.0.0/0".parse().unwrap();
451509
assert!(is_always_blocked_net(net));
452510
}
453511

454512
#[test]
455513
fn test_always_blocked_net_v6_broad_containing_loopback() {
456-
let net: ipnet::IpNet = "::/0".parse().unwrap();
514+
let net: IpNet = "::/0".parse().unwrap();
457515
assert!(is_always_blocked_net(net));
458516
}
459517

460518
#[test]
461519
fn test_always_blocked_net_v6_ipv4_mapped_loopback_single() {
462-
let net: ipnet::IpNet = "::ffff:127.0.0.1/128".parse().unwrap();
520+
let net: IpNet = "::ffff:127.0.0.1/128".parse().unwrap();
463521
assert!(is_always_blocked_net(net));
464522
}
465523

466524
#[test]
467525
fn test_always_blocked_net_v6_ipv4_mapped_link_local_single() {
468-
let net: ipnet::IpNet = "::ffff:169.254.0.1/128".parse().unwrap();
526+
let net: IpNet = "::ffff:169.254.0.1/128".parse().unwrap();
469527
assert!(is_always_blocked_net(net));
470528
}
471529

@@ -474,25 +532,82 @@ mod tests {
474532
// ::ffff:168.0.0.0/103 has a public network address (168.0.0.0) but
475533
// the range covers 168.0.0.0–169.255.255.255, which includes the
476534
// link-local block 169.254.0.0/16.
477-
let net: ipnet::IpNet = "::ffff:168.0.0.0/103".parse().unwrap();
535+
let net: IpNet = "::ffff:168.0.0.0/103".parse().unwrap();
478536
assert!(is_always_blocked_net(net));
479537
}
480538

481539
#[test]
482540
fn test_always_blocked_net_v6_ipv4_mapped_broad_spans_loopback() {
483541
// ::ffff:64.0.0.0/98 has a public network address (64.0.0.0) but the
484542
// range covers 64.0.0.0–127.255.255.255, which includes loopback.
485-
let net: ipnet::IpNet = "::ffff:64.0.0.0/98".parse().unwrap();
543+
let net: IpNet = "::ffff:64.0.0.0/98".parse().unwrap();
486544
assert!(is_always_blocked_net(net));
487545
}
488546

489547
#[test]
490548
fn test_always_blocked_net_v6_ipv4_mapped_allows_public() {
491549
// ::ffff:8.8.8.8/128 is a public address — should not be blocked.
492-
let net: ipnet::IpNet = "::ffff:8.8.8.8/128".parse().unwrap();
550+
let net: IpNet = "::ffff:8.8.8.8/128".parse().unwrap();
493551
assert!(!is_always_blocked_net(net));
494552
}
495553

554+
// -- is_internal_net --
555+
556+
#[test]
557+
fn test_internal_net_rfc1918_contained_and_supernet() {
558+
for cidr in ["10.1.0.0/16", "8.0.0.0/5"] {
559+
assert!(is_internal_net(cidr.parse().unwrap()), "{cidr}");
560+
}
561+
}
562+
563+
#[test]
564+
fn test_internal_net_cgnat_contained_and_supernet() {
565+
for cidr in ["100.64.0.0/10", "100.0.0.0/9"] {
566+
assert!(is_internal_net(cidr.parse().unwrap()), "{cidr}");
567+
}
568+
}
569+
570+
#[test]
571+
fn test_internal_net_ipv4_special_use_ranges() {
572+
for cidr in [
573+
"192.0.0.0/24",
574+
"192.0.2.0/24",
575+
"198.18.0.0/15",
576+
"198.51.100.0/24",
577+
"203.0.113.0/24",
578+
"255.255.255.255/32",
579+
] {
580+
assert!(is_internal_net(cidr.parse().unwrap()), "{cidr}");
581+
}
582+
}
583+
584+
#[test]
585+
fn test_internal_net_ipv6_ula() {
586+
for cidr in ["fc00::/7", "fd00::/8"] {
587+
assert!(is_internal_net(cidr.parse().unwrap()), "{cidr}");
588+
}
589+
}
590+
591+
#[test]
592+
fn test_internal_net_ipv4_mapped_cgnat_supernet() {
593+
let net: IpNet = "::ffff:100.0.0.0/105".parse().unwrap();
594+
assert!(is_internal_net(net));
595+
}
596+
597+
#[test]
598+
fn test_internal_net_always_blocked() {
599+
for cidr in ["127.0.0.0/8", "fe80::/10"] {
600+
assert!(is_internal_net(cidr.parse().unwrap()), "{cidr}");
601+
}
602+
}
603+
604+
#[test]
605+
fn test_internal_net_allows_large_public_ranges() {
606+
for cidr in ["8.0.0.0/8", "::ffff:8.0.0.0/104"] {
607+
assert!(!is_internal_net(cidr.parse().unwrap()), "{cidr}");
608+
}
609+
}
610+
496611
// -- is_internal_ip --
497612

498613
#[test]

0 commit comments

Comments
 (0)