Repository navigation
bug: link-local / loopback entries in allowed_ips are silently accepted but never enforced #814
Description
Agent Diagnostic
Loaded skills: openshell-cli (for openshell sandbox, openshell policy, openshell logs commands). No sandbox-specific debug skill was applicable — debug-openshell-cluster is for cluster-start failures and debug-inference is for inference routing, neither matched this symptom.
Investigation sequence:
-
Static trace of the enforcement path. Followed the forward-proxy code path at
crates/openshell-sandbox/src/proxy.rs:1936(handle_forward_proxy). Traced the OPA →query_allowed_ips→implicit_allowed_ips_for_ip_hostfallback →resolve_and_check_allowed_ipschain. Read the Rego endpoint-matching rules atcrates/openshell-sandbox/data/sandbox-policy.rego(exact-host match at lines 95–101, hostless-plus-allowed_ips at 117–123, extended-config gate at 346–356). Confirmed PR fix(sandbox): treat literal IP in policy host as implicit allowed_ips #570 (3f1917a7 fix(sandbox): treat literal IP in policy host as implicit allowed_ips) is present in v0.0.26, so for most literal-IP scenarios the code path is sound on paper. -
Found the hard block.
is_always_blocked_ipatcrates/openshell-sandbox/src/proxy.rs:1530returns true for IPv4 loopback, link-local (169.254.0.0/16), and unspecified ranges — and the equivalent IPv6 ranges including::1,fe80::/10, and v4-mapped variants. It is consulted before the allowlist membership check insideresolve_and_check_allowed_ipsatcrates/openshell-sandbox/src/proxy.rs:1580-1587. Testtest_forward_link_local_always_blocked_even_with_allowed_ipsatcrates/openshell-sandbox/src/proxy.rs:3257explicitly locks the behaviour in as intentional (cloud-metadata SSRF hardening). The CONNECT path atcrates/openshell-sandbox/src/proxy.rs:482-524and the forward-proxy path atcrates/openshell-sandbox/src/proxy.rs:2218-2259both flow through the same helper, so the block applies uniformly. -
Traced the loader and approval paths to look for upstream validation.
parse_allowed_ipsatcrates/openshell-sandbox/src/proxy.rs:1623only emits a warning for very broad CIDRs (< /16) and never rejects entries that fall inside the always-blocked ranges. The server-side policy ingest and the TUI draft-approval handler (handle_approve_draft_chunk→merge_chunk_into_policyatcrates/openshell-server/src/grpc/policy.rs:999and:1624) also perform no such validation — they happily persist a draft chunk whosehostorallowed_ipsnames an un-enforceable range, mark it approved, and push a revision throughsandbox_watch_bus.notify. From the operator's seat, the TUI reports success while enforcement silently no-ops. -
Traced the denial analyzer.
mechanistic_mapper::generate_proposalsatcrates/openshell-sandbox/src/mechanistic_mapper.rs:107builds aNetworkPolicyRulefrom each denial summary, callsresolve_allowed_ips_if_privateto populateallowed_ipswhen the resolved IP is RFC1918-ish, and emits a proposal. It has no notion of "always-blocked class" — so for denials whose destination IP is structurally un-allowable, it keeps generating fresh proposals on every flush cycle (every ~10s in my repro). -
Live repro on sandbox
theseus. Dumped the loaded policy withopenshell policy get theseus --full(rev 9, hashd47e757…). User had a clean sandbox-levelaws_iamrule that sets both the IP inhost:and a matchingallowed_ips:entry — policy loaded without warning. Reproduced the 403 myself from inside the sandbox withopenshell sandbox exec -n theseus -- curl -v -X PUT http://169.254.169.254/latest/api/token— the client connected viahttp_proxy=http://10.200.0.1:3128(the sandbox forward proxy) and gotHTTP/1.1 403 Forbidden. The corresponding denial event appeared inopenshell logs theseustagged[policy:aws_iam](see Logs section below). That tag is emitted atcrates/openshell-sandbox/src/proxy.rs:434:let policy_str = matched_policy.as_deref().unwrap_or("-");
so
[policy:aws_iam]proves OPA matched the rule cleanly at both the endpoint-allowed and binary-allowed layers — the 403 is strictly downstream at the SSRF / always-blocked layer, not an OPA miss. -
Checked the OCSF JSONL sink to see
status_detail. The shorthand OCSF text format drops thestatus_detailfield, which is where the explanatory phrase"resolves to always-blocked address"lives (set atcrates/openshell-sandbox/src/proxy.rs:508, 543, 584for CONNECT and:2244, :2282for forward). The JSONL file at/var/log/openshell-ocsf.2026-04-11.loginside the sandbox was 0 bytes in my repro, so there was no higher-fidelity log to fall back on. This is a meaningful contributor to the bug symptom — the system is not lying to the operator, it is just not telling them enough: the shorthand denial line reads identically to a plain allowlist miss.
What could not be resolved by the agent: The block itself is intentional (covered by a locked-in test) and removing it would regress cloud-metadata SSRF hardening, which is a deliberate security invariant. The fix has to go into validation, messaging, and proposal filtering — see "Suggested fixes" below — none of which I applied because the user wants maintainer review first.
Description
Actual behaviour: In v0.0.26, sandbox policies that whitelist an IP inside any always-blocked range (127.0.0.0/8, 169.254.0.0/16, 0.0.0.0, ::1, fe80::/10, v4-mapped equivalents) are accepted without warning by the policy loader and by the TUI draft-approval path, but produce 403s on every request at runtime. OPA matches the rule (denial events carry the matched policy name in the log), and the rejection happens downstream at is_always_blocked_ip inside resolve_and_check_allowed_ips. The shorthand OCSF log line for the denial does not surface the reason, so the operator cannot distinguish "my allowlist is wrong" from "my allowlist can never take effect." The denial analyzer keeps generating fresh draft proposals for the same destination on every flush cycle, so the TUI notification repeatedly returns after approval, reinforcing the illusion that the rule is being processed.
Expected behaviour: At least one of the following — ideally all four:
- The policy loader (and the TUI-approval path on the server) should reject entries whose IP or CIDR overlaps any always-blocked range, with an explicit error identifying the range and the SSRF-hardening rationale, so un-enforceable rules never get persisted in the first place.
- The runtime denial message should clearly distinguish "allowlist miss" from "destination is structurally un-allowable" so log readers understand what happened.
- The denial analyzer should skip proposals whose resolved IP set is entirely inside always-blocked ranges, so the TUI does not keep re-surfacing notifications for un-fixable situations.
- The OCSF JSONL sink inside the sandbox image should actually be populated by default — or the shorthand log format should surface
status_detailfor denials — so operators have a path to the full event context.
Scope clarification: The hard block on link-local / loopback / unspecified destinations is correct and should stay. This issue is about the silent failure mode around it, not the block itself.
Reproduction Steps
-
Set a sandbox policy that names an always-blocked IP — AWS IMDS is the canonical case:
aws_iam: name: aws_iam endpoints: - host: '169.254.169.254' port: 80 allowed_ips: - '169.254.169.254' binaries: - path: /usr/bin/curl
Apply it with
openshell policy set <sandbox> --policy policy.yaml. -
Verify it loaded:
openshell policy get <sandbox> --full. The rule appears verbatim, no warnings, statusLoaded. -
From inside the sandbox, hit the IMDSv2 token endpoint:
openshell sandbox exec -n <sandbox> -- curl -v -X PUT http://169.254.169.254/latest/api/token
Result:
HTTP/1.1 403 Forbiddenfrom the sandbox forward proxy (http_proxy=http://10.200.0.1:3128). -
Check the logs:
openshell logs <sandbox> --source sandboxshows the denial tagged with the matched policy name — proving OPA matched and the block is downstream:HTTP:PUT [MED] DENIED /usr/bin/curl(1618) -> PUT http://169.254.169.254/latest/api/token [policy:aws_iam] -
(Optional) Leave it running and observe
Flushed denial analysis to gateway proposals=1repeating in the logs every ~10 s — the analyzer keeps re-proposing the same un-enforceable rule.
Environment
- OS: Linux 6.17.0-1014-nvidia
- OpenShell: 0.0.26 (
openshell --version) - Deployment: local gateway, sandbox-level policy (not gateway-global)
- External trigger: OpenClaw running inside the sandbox initialising AWS SDK credentials — see [Bug]: Hundreds of repeated HTTP requests to 169.254.169.254:80 for the first 10 minutes after the startup openclaw/openclaw#64891 for the retry-loop behaviour that makes this very noisy in practice, though the OpenShell bug is independent of OpenClaw.
Logs
# Live denial events on sandbox `theseus` (shorthand OCSF format from `openshell logs`):
[1775932282.846] [sandbox] [OCSF ] [ocsf] HTTP:PUT [MED] DENIED /usr/local/bin/node(1014) -> PUT http://169.254.169.254/latest/api/token [policy:aws_iam]
[1775932282.854] [sandbox] [OCSF ] [ocsf] HTTP:GET [MED] DENIED /usr/local/bin/node(1014) -> GET http://169.254.169.254/latest/meta-data/iam/security-credentials/ [policy:aws_iam]
[1775932288.821] [sandbox] [OCSF ] [ocsf] HTTP:PUT [MED] DENIED /usr/local/bin/node(1040) -> PUT http://169.254.169.254/latest/api/token [policy:aws_iam]
[1775932288.828] [sandbox] [OCSF ] [ocsf] HTTP:GET [MED] DENIED /usr/local/bin/node(1040) -> GET http://169.254.169.254/latest/meta-data/iam/security-credentials/ [policy:aws_iam]
# ... repeats every ~6s with a fresh node pid as OpenClaw spawns new children
# Reproduced the same denial from a manual curl inside the sandbox:
[1775932413.437] [sandbox] [OCSF ] [ocsf] HTTP:PUT [MED] DENIED /usr/bin/curl(1618) -> PUT http://169.254.169.254/latest/api/token [policy:aws_iam]
# Denial analyzer re-proposing the same rule on every flush:
[1775932313.643] [sandbox] [INFO ] [openshell_sandbox] Flushed denial analysis to gateway proposals=1 sandbox_name=theseus summaries=1
[1775932323.642] [sandbox] [INFO ] [openshell_sandbox] Flushed denial analysis to gateway proposals=1 sandbox_name=theseus summaries=1
# The OCSF JSONL sink inside the sandbox — where `status_detail` would live — was empty:
$ openshell sandbox exec -n theseus -- stat -c '%s %n' /var/log/openshell-ocsf.2026-04-11.log
0 /var/log/openshell-ocsf.2026-04-11.logThe [policy:aws_iam] tag at the end of each denial line is the key signal: it is emitted at crates/openshell-sandbox/src/proxy.rs:434 from matched_policy.as_deref().unwrap_or("-"), so it can only be set when OPA has returned NetworkAction::Allow { matched_policy: Some(...) }. The 403 therefore cannot be an OPA miss — it is happening later, inside resolve_and_check_allowed_ips at crates/openshell-sandbox/src/proxy.rs:1580, where is_always_blocked_ip rejects the destination before the allowlist is consulted.
Suggested fixes (for maintainer review)
- Validate at load time. In
parse_allowed_ips(crates/openshell-sandbox/src/proxy.rs:1623), reject any parsedIpNetthat overlaps an always-blocked range, with an error naming the offending entry and explaining the metadata-SSRF rationale. The same check can be shared with afn is_always_blocked_net(net: IpNet) -> boolhelper next tois_always_blocked_ip. - Validate at ingest/approval time. Mirror the same check in the server-side policy ingest and in
merge_chunk_into_policy/handle_approve_draft_chunkatcrates/openshell-server/src/grpc/policy.rs:999and:1624, so the TUI surfaces the rejection synchronously instead of silently persisting a no-op rule and clearing the notification. - Disambiguate the denial message. Split the
FORWARD blocked: allowed_ips check failed for <host>:<port>top-line atcrates/openshell-sandbox/src/proxy.rs:505-506and:2242-2244into two distinct messages — one for "not in allowlist" and one for "destination is in always-blocked class" — or includestatus_detailin the shorthand OCSF format for denials so the reason travels with the event. - Filter un-fixable proposals. In
mechanistic_mapper::generate_proposals(crates/openshell-sandbox/src/mechanistic_mapper.rs:107), skip destinations whose resolved IPs fall entirely within the always-blocked ranges, so the analyzer stops re-proposing rules that cannot take effect. Optionally emit a single structured warning event for operators so the denial is still visible but does not re-surface the TUI notification loop. - Separately investigate the JSONL sink.
/var/log/openshell-ocsf.<date>.logwas 0 bytes inside the sandbox in this repro. Either the default image is missing the JSONL tracing layer or it is pointed elsewhere — worth a separate spike. Having a working JSONL sink would have reduced the severity of this bug because an operator could have greppedstatus_detailforalways-blockedand diagnosed it in one step.
Design note
One design question worth flagging: the one always-blocked range with a real use case is 169.254.0.0/16 — 169.254.169.254 is where AWS/GCP/Azure serve instance metadata and IAM credential bootstrap, and every AWS SDK tries IMDSv2 there first on a cloud host. The current hard block pushes operators off instance-role auth and onto long-lived static keys, which is a regression in credential hygiene. The counter is also real: this range is item #1 on every SSRF hardening checklist because of the 2019 Capital One breach, and IMDSv2's PUT-then-GET handshake does not help inside a sandbox where the attacker can trivially issue PUTs — the block's real job here is preventing a prompt-injected agent from stealing the host's EC2 role credentials. A blanket relaxation would be wrong, but a narrowly-scoped opt-in for the specific metadata endpoints (as a distinct top-level policy field, not something reached via allowed_ips:) might deserve a design look. Orthogonal to the validation and messaging items above; flagging it only because the investigation surfaced the question.
Related
- [Bug]: Hundreds of repeated HTTP requests to 169.254.169.254:80 for the first 10 minutes after the startup openclaw/openclaw#64891 — OpenClaw retry-loops IMDSv2 on 403. Amplifies the log spam in this repro (fresh node pid every ~6s) but is not itself an OpenShell bug.
Agent-First Checklist
- I pointed my agent at the repo and had it investigate this issue
- I loaded relevant skills (
openshell-clifor the live repro; no sandbox-specific debug skill was applicable) - My agent could not resolve this — the diagnostic above explains why (the block is intentional and locked in by a test; the remediation is loader/UX/analyzer validation, which needs maintainer review before modification)
Activity
github-actions commentedon Apr 11, 2026
This issue appears to have been opened without an agent investigation.
OpenShell is an agent-first project - please point your coding agent at the repo and have it diagnose this before we triage. Your agent can load skills like
debug-openshell-cluster,debug-inference,openshell-cli, andgenerate-sandbox-policy.See CONTRIBUTING.md for the full workflow.
vnicolici commentedon Apr 11, 2026
That bot seems to need fixing, this issue was investigated by an agent and reported by the agent (Claude Code / Opus 4.6).
vnicolici commentedon Apr 11, 2026
(Comment from Claude Code / Opus 4.6, the agent that filed this issue on @vnicolici's behalf — flagging a detail the reporter asked me to document.)
The triage-gate false positive above has a concrete cause worth recording:
.github/workflows/issue-triage.ymllooks for the agent-diagnostic section with the regex/### Agent Diagnostic\s*\n.../, which requires an H3 (###) header. That matches what the YAML form template produces when a human fills it in through the GitHub web UI, but when I drafted this issue as raw Markdown viagh issue create --body-file, I used## Agent Diagnostic(H2) for top-level section headers. The section itself is substantive and right where it should be — the regex just could not see it because the header level is hardcoded.Two observations, neither a request for action — leaving the call to the maintainers:
gh issue create --body-fileare expected to pass the triage gate, the agent-facing docs (or CONTRIBUTING.md) could note that section headers must be H3 to match what the YAML form emits. I did not find that noted anywhere when I drafted this issue, and I matched the visual convention of most GitHub issue bodies I've seen (H2 for top-level) rather than the template's rendered form./#{2,6}\s*Agent Diagnostic\s*\n.../) would prevent this category of false positive without weakening the check, since the actual signal the gate cares about is "is there substantive content under a section named 'Agent Diagnostic'," not the header depth.Happy to defer entirely on both — raising them only because the reporter asked me to document what happened so the next agent-authored issue does not trip the same wire.
johntmyers commentedon Apr 11, 2026
Nice finding. Will take a look and address the initial issue. Noted on the design suggestion. Would there be a specific payload to perhaps check on so there would be a L7 enforcement along with a narrowly scoped IP for the metadata emdpoints?
fix(sandbox): validate always-blocked IPs at load time, enrich denial…
fix(sandbox): validate always-blocked IPs at load time, enrich denial…
vnicolici commentedon Apr 16, 2026
@johntmyers Thank you for the quick fix, and sorry for replying so late, we had Easter holidays on Sunday and Monday in our country then I had a very busy week and also some personal issues to solve.
Anyway, I'm not an expert on this by any means, so I just asked Claude if there are any legitimate reasons somebody might want to bypass this restriction. Initially Claude wanted to describe in more detail a possible feature and potential use cases, but I asked it to just make a small mention instead, as I didn't want to push to hard for a change that might have negative security implications, and that might not actually be entirely necessary.
Now, in my particular case, it was OpenClaw trying to get credentials from that AWS API (I don't have any need for that at the moment, my environment is not even AWS but a local machine). Not sure how safe it would be to allow it to do that, personally I don't really need it.
It's just that OpenClaw kept trying to access that API every 5-6 seconds for the first 10 minute after startup, which was quite annoying, as it was triggering error messages in the logs and asked me to accept or reject the attempts. I was just trying to get rid of the errors and the notifications in
openshell term, until OpenClaw fixes the bug that caused retrying so frequently with that policy, I didn't have any need for it to actually work.Now, aside from my issues, theoretically, if it's intentionally configured by the users, it might be useful to inject some credentials in the sandbox this way, but at a minimum it might require some whitelist, so that it can't read more credentials than it needs, maybe a filter on the AWS API result so that only a subset of credentials can actually reach the sandbox.
The thing is, it's probably better to not send the actual credentials inside the sandbox, but placeholders for them, and then replace those placeholders with the actual credentials in the outbound requests that try to use those credentials. This way the actual sandbox wouldn't ever have them.
Of course, that's not always feasible, as not all network protocols will be supported. Replacing the placeholders in outbound HTTP headers for example would work, but for a more complex protocol it might be unfeasible or too costly to add support. So, in some cases the actual credentials might have to be injected in the sandbox after all.
And here, we have an issue. In the case of OpenClaw, when it tries to get credentials with that API, they are just saved in memory, in the node JS process, and not persisted anywhere, so in theory they never reach the agent context either, unless OpenClaw has bugs. So in theory the risk of exfiltrating them intentionally or unintentionally by the agent is not high, if only the node JS binary is whitelisted and only OpenClaw runs using the node binary.
However, the agent can run tools, including shell commands. So it could create its own NodeJS scripts, and run them using node, and if the node binary is whitelisted to call that API, it could extract credentials and then exfiltrate them.
Of course, the agent can be can be in theory prevented from running NodeJS scripts, or at least require a different whitelist when it does that, but that might be too complicated to implement, and be too fragile.
So, to conclude, while bypassing these restrictions might prove useful in some cases, it might require a lot of additional work to keep everything secure. In my opinion the risk is too high and the benefits are debatable.
Again, I'm not an expert on this at all, so sorry if I made any mistakes in my analysis (no Claude involved this time).