Skip to content

Commit e1ef863

Browse files
SohamDas2021Copilot
andcommitted
Reject an unenforceable allowLocalNetwork deny on value, not on presence
allowLocalNetwork=false under a shared namespace is refused at 0.8 because Bubblewrap cannot deliver the deny. That refusal was gated on a parse-time allow_local_network_specified flag, so it fired only for callers who wrote the field. false is the schema default *and* a deny, so an omitted field is still a request for inbound denial: a bare defaultPolicy='allow' ran with the deny silently unenforced. Default-deny is the contract, and a backend that cannot honor a restriction refuses it rather than choosing default-allow on the caller's behalf. IsolationSession already takes the strict reading of this exact field: validate_provision_network_policy requires allow_local_network by value and refuses an absent policy as "a restriction the backend cannot honor". The guard is now dropped so Bubblewrap agrees, and the flag it existed for goes with it -- that guard was its only production consumer, so models.rs and network_parser.rs leave this change with no net diff. The rejection stays actionable: a shared namespace is still reachable by acknowledging the exposure with allowLocalNetwork=true, which the error text now names. No existing config changes behavior -- both shared-namespace corpus configs set the field explicitly, and every other 0.8 Bubblewrap config resolves to a private namespace via a proxy or host rules. Coverage: the guard had none. Deleting it outright left all 202 tests passing, because the one test that looked like it covered this used defaultPolicy='block', whose private namespace short-circuits the mode arm. Two unit tests now pin the omitted and acknowledged cases (204 total; a reintroduced guard fails three), and two e2e configs pin them end to end, the acknowledged one asserting the sandbox really is in the host netns so "reject the deny" cannot be satisfied by refusing every shared-namespace request. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ae9db9df-6439-46c3-9cb4-fd70e7cc43a3
1 parent 1bdee51 commit e1ef863

8 files changed

Lines changed: 95 additions & 20 deletions

File tree

‎docs/bwrap-support/bubblewrap-backend.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,14 @@ failing silently. Windows (AppContainer's `privateNetworkClientServer`
328328
capability) and macOS (Seatbelt's `(allow network-inbound (local ip))`)
329329
enforce the field at the syscall level; this divergence is Linux-specific.
330330

331+
Row 2 is keyed on the value, not on whether the caller wrote the field.
332+
`false` is the schema's default *and* a deny, so an omitted `allowLocalNetwork`
333+
is still a request for inbound denial and is rejected the same way: a bare
334+
`defaultPolicy: "allow"` does not silently opt out of the deny it inherits.
335+
Callers who want the shared namespace acknowledge the exposure with
336+
`allowLocalNetwork: true` (row 4), the same acknowledgment IsolationSession
337+
requires for this field.
338+
331339
### Process Settings
332340

333341
Standard `process` fields work as expected:

‎src/backends/bubblewrap/common/src/bwrap_command.rs‎

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,8 @@ pub const BWRAP_LOCAL_NETWORK_SHARED_NS: &str =
240240
sandbox shares the host network namespace (defaultPolicy='allow'). \
241241
The sandboxed process can still bind, listen and accept on host-local addresses. For \
242242
an unreachable sandbox use defaultPolicy='block' with no host rules and no proxy, \
243-
which applies --unshare-net.";
243+
which applies --unshare-net; to keep the shared namespace, acknowledge the exposure \
244+
with allowLocalNetwork=true.";
244245

245246
/// Rejection text for `allowLocalNetwork=true` under a private namespace.
246247
pub const BWRAP_LOCAL_NETWORK_PRIVATE_NS: &str =
@@ -307,17 +308,15 @@ pub fn external_proxy_host_rules_rejection(request: &ExecutionRequest) -> Option
307308
/// `builtinTestServer` has no address until the proxy starts, which is after
308309
/// validation.
309310
///
310-
/// An unset `allowLocalNetwork` is never rejected. The field defaults to
311-
/// `false`, so rejecting on the value alone would fail every 0.8 request that
312-
/// shares the host namespace without asking for inbound policy at all. The
313-
/// warning path keeps the looser check: it only logs.
311+
/// Rejects on the value, not on whether the caller wrote the field. `false` is
312+
/// the schema default *and* a deny, so an omitted `allowLocalNetwork` still
313+
/// asks for inbound denial. Callers wanting the shared namespace acknowledge
314+
/// the exposure with `allowLocalNetwork=true`. The warning path keeps the
315+
/// looser check: it only logs.
314316
pub fn local_network_rejection(request: &ExecutionRequest) -> Option<&'static str> {
315317
if !rejects_unhonorable_network(request) {
316318
return None;
317319
}
318-
if !request.policy.allow_local_network && !request.policy.allow_local_network_specified {
319-
return None;
320-
}
321320
let mode =
322321
ResolvedNetworkMode::from_request(request, request.policy.network_proxy.is_enabled());
323322
local_network_diagnostic_for_mode(request, mode)
@@ -909,13 +908,36 @@ mod tests {
909908
let mut r = base_request();
910909
r.schema_version = "0.8.0-alpha".into();
911910
r.policy.default_network_policy = NetworkPolicy::Allow;
912-
r.policy.allow_local_network_specified = true;
913911
let msg = local_network_rejection(&r).expect("shared netns cannot honor the deny");
914912
assert!(msg.contains("allowLocalNetwork=false"));
915913
// The text is reused verbatim as an error, so it must carry no severity.
916914
assert!(!msg.contains("WARNING"));
917915
}
918916

917+
#[test]
918+
fn an_omitted_local_network_is_rejected_like_an_explicit_deny_at_0_8() {
919+
// `false` is the schema default *and* a deny, so silence still asks for
920+
// inbound denial.
921+
let mut r = base_request();
922+
r.schema_version = "0.8.0-alpha".into();
923+
r.policy.default_network_policy = NetworkPolicy::Allow;
924+
assert!(!r.policy.allow_local_network, "default is the deny posture");
925+
let msg = local_network_rejection(&r).expect("an omitted deny is still a deny");
926+
assert!(msg.contains("allowLocalNetwork=false"));
927+
// Actionable only if it names the acknowledgment.
928+
assert!(msg.contains("allowLocalNetwork=true"));
929+
}
930+
931+
#[test]
932+
fn acknowledged_local_network_exposure_is_accepted_at_0_8() {
933+
// The escape hatch from the rejection above.
934+
let mut r = base_request();
935+
r.schema_version = "0.8.0-alpha".into();
936+
r.policy.default_network_policy = NetworkPolicy::Allow;
937+
r.policy.allow_local_network = true;
938+
assert!(local_network_rejection(&r).is_none());
939+
}
940+
919941
#[test]
920942
fn honored_local_network_is_not_rejected_at_0_8() {
921943
// block + no host rules + no proxy is a private netns, which satisfies

‎src/backends/bubblewrap/common/src/bwrap_runner.rs‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1260,7 +1260,6 @@ mod tests {
12601260
let mut req = base_request();
12611261
req.schema_version = "0.8.0-alpha".into();
12621262
req.policy.default_network_policy = wxc_common::models::NetworkPolicy::Allow;
1263-
req.policy.allow_local_network_specified = true;
12641263

12651264
let err = BubblewrapScriptRunner::new().validate(&req).unwrap_err();
12661265
assert!(

‎src/core/wxc_common/src/models.rs‎

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -703,15 +703,6 @@ pub struct ContainerPolicy {
703703
/// and accept incoming connections. Independent of `default_network_policy`
704704
/// (which governs outbound traffic).
705705
pub allow_local_network: bool,
706-
/// Whether the caller supplied `network.allowLocalNetwork` on the wire.
707-
///
708-
/// Field-level twin of `network_specified`, needed because this field
709-
/// defaults to `false`, which is also the value a caller may set
710-
/// explicitly. Backends that reject an unenforceable `false` must not
711-
/// reject a request that never asked for one. Parse-derived, never on the
712-
/// wire.
713-
#[serde(skip)]
714-
pub allow_local_network_specified: bool,
715706
pub allowed_hosts: Vec<String>,
716707
pub blocked_hosts: Vec<String>,
717708
/// Outbound CIDR, protocol, and port policy.

‎src/core/wxc_common/src/network_parser.rs‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,6 @@ fn apply_legacy_network(
244244
}
245245
if let Some(allow) = network.allow_local_network {
246246
policy.allow_local_network = allow;
247-
policy.allow_local_network_specified = true;
248247
}
249248
if let Some(hosts) = network.allowed_hosts {
250249
policy.allowed_hosts = hosts;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"version": "0.8.0-alpha",
3+
"containerId": "CLI-Bubblewrap-LocalNet-Acknowledged",
4+
"containment": "bubblewrap",
5+
"process": {
6+
"commandLine": "bash -c 'set -u; echo SANDBOX_NETNS=$(readlink /proc/self/ns/net); echo LOCALNET_ACK_OK'"
7+
},
8+
"network": {
9+
"defaultPolicy": "allow",
10+
"allowLocalNetwork": true
11+
}
12+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"version": "0.8.0-alpha",
3+
"containerId": "CLI-Bubblewrap-LocalNet-Omitted-Rejected",
4+
"containment": "bubblewrap",
5+
"process": {
6+
"commandLine": "echo LOCALNET_SHOULD_NOT_RUN"
7+
},
8+
"network": {
9+
"defaultPolicy": "allow"
10+
}
11+
}

‎tests/scripts/run_bwrap_localnet_test.sh‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,39 @@ assert_rejected "allowLocalNetwork=true under a private namespace" \
9696
"bubblewrap_network_localnet_private_rejected.json" \
9797
"is confined to the sandbox's own network namespace"
9898

99+
# The same rejection with the field omitted. `false` is the schema default *and*
100+
# a deny, so silence still asks for inbound denial. Separate from the explicit
101+
# case because a presence check would pass that one and fail this one.
102+
assert_rejected "an omitted allowLocalNetwork on a shared namespace" \
103+
"bubblewrap_network_localnet_omitted_rejected.json" \
104+
"is not enforced while the sandbox shares the host network namespace"
105+
106+
# The escape hatch from both cases above. Pinned because "reject the deny" is
107+
# also satisfiable by rejecting every shared-namespace request, which would
108+
# leave these callers with no expressible policy.
109+
echo "Running Bubblewrap localnet test: acknowledged exposure is accepted..."
110+
ACK_RC=0
111+
ACK_OUT=$("$LXC_EXEC" --experimental --allow-testing-features \
112+
"$REPO_DIR/tests/configs/bubblewrap_network_localnet_acknowledged.json" 2>&1) \
113+
|| ACK_RC=$?
114+
if [ "$ACK_RC" != 0 ]; then
115+
echo "$ACK_OUT"
116+
echo "FAIL: acknowledged localnet (exited $ACK_RC; the acknowledgment is not honored)"
117+
exit 1
118+
fi
119+
if ! grep -q LOCALNET_ACK_OK <<<"$ACK_OUT"; then
120+
echo "$ACK_OUT"
121+
echo "FAIL: acknowledged localnet (succeeded without running the workload)"
122+
exit 1
123+
fi
124+
ACK_NETNS="$(sed -n 's/^SANDBOX_NETNS=//p' <<<"$ACK_OUT" | tail -n 1)"
125+
if [ "$ACK_NETNS" != "$HOST_NETNS" ]; then
126+
echo "$ACK_OUT"
127+
echo "FAIL: acknowledged localnet (expected the host netns $HOST_NETNS, got $ACK_NETNS)"
128+
exit 1
129+
fi
130+
echo "PASS: acknowledged exposure is accepted"
131+
99132
# The pre-0.8 twin of the first case. GHCP consumes Bubblewrap proxy mode on
100133
# 0.6/0.7, so the rejection above must be invisible there.
101134
#

0 commit comments

Comments
 (0)