Skip to content

microsandbox backend: network-policy credential injection never happens - the placeholder is sent to the real origin #2440

Description

@tommy5dollar

Link to a minimal reproduction

https://github.com/tommy5dollar/eve-microsandbox-injection-repro

Steps to reproduce

The reproduction needs a Linux host with read/write access to /dev/kvm and network access to a
public HTTPS echo endpoint. This defect lives at a real TLS boundary, so it cannot be reproduced
offline.

git clone https://github.com/tommy5dollar/eve-microsandbox-injection-repro
cd eve-microsandbox-injection-repro
npm install
npm test

npm test runs one assertion set against three targets:

target configuration
stock eve's applyMicrosandboxNetwork, unmodified
fixed the same call, plus one .tls((t) => t.interceptedPorts([443]).verifyUpstream(true))
control no eve: the same microsandbox secret and the same interception, built directly

The stock run is expected to be red. That red is the reproduction, not a broken repository.
Only the fixed and control runs decide the exit code, so npm test exits 0 when the
reproduction holds.

fixed differs from stock by exactly one builder call. lib/tls-shim.mjs proxies the sandbox
builder so eve's own network(configure) call gains one appended .tls(...). eve's module is not
patched, not copied and not re-modelled - both runs execute the same shipped code from
node_modules.

What each target does, in one paragraph: it builds a microsandbox microVM the way
createMicrosandbox builds one, applies the network policy below, then runs a single HTTPS GET
from inside the guest carrying the placeholder eve exposed, and reads back the authorization
header the origin reports.

{
  allow: {
    "postman-echo.com": [
      { transform: [{ headers: { authorization: "Bearer EVE-REPRO-REAL-VALUE-NOT-A-SECRET" } }] },
    ],
  },
}

Single-shot variants, if the suite output is hard to read:

node probe.mjs            # eve's chain, unmodified; exits 1 when the placeholder reached the origin
node probe.mjs fixed
node probe.mjs control

If your login session predates being added to the kvm group, run each command under
sg kvm -c "...". npx msb doctor reports the state of the host and prints the fix.

Current vs. expected behavior

Expected. A transform in a sandbox network policy substitutes the real credential at the
firewall. eve's own SandboxNetworkPolicy documentation states the purpose: "A per-domain
transform injects headers at the firewall so secrets never enter the sandbox process."

Actual, on the microsandbox backend. No substitution happens. The literal placeholder eve
generated (__EVE_MSB_SECRET_<hash>__) is sent to the real origin. The request succeeds at the
transport level, nothing throws, and no event distinguishes this from a working injection.

applyMicrosandboxNetwork builds a microsandbox secret per credentialed host with
injectHeaders(true), injectBasicAuth(true), requireTlsIdentity(true) and allowHost(...),
and sets trustHostCAs(true) on the network builder. It never calls .tls(...), so TLS
interception is never configured. Without interception the boundary cannot read or rewrite an
HTTPS request, so a requireTlsIdentity secret can never match and never substitutes.

Three variants, one variable changed:

variant configuration authorization seen by the origin
1. stock eve's chain (policyJson + secret + trustHostCAs, no .tls) __EVE_MSB_SECRET_a47a260bb090c8552fc8b384__
2. fixed identical, plus .tls((t) => t.interceptedPorts([443]).verifyUpstream(true)) Bearer EVE-REPRO-REAL-VALUE-NOT-A-SECRET
3. control no eve: the same secret and the same interception, built directly Bearer EVE-REPRO-REAL-VALUE-NOT-A-SECRET

Variant 3 is there to show that microsandbox substitutes a requireTlsIdentity secret correctly
when interception is on. The defect is in eve's translation, not in the substrate.

The consequence for a user is quiet. A brokered token reaches the origin as a placeholder string.
git clone against a brokered credential gets a 401, which is at least loud; any other
at-boundary credential simply fails to authenticate, or worse - reaches a host that ignores the
header. Nothing in the sandbox layer reports a problem, because nothing was denied and nothing
threw.

eve version

eve@0.44.3 (npm latest; also reproduces on 0.44.0 and 0.39.3; dist/src/execution/sandbox/bindings/microsandbox-network.js is byte-identical across all three - not a regression)

Environment

OS: Ubuntu 24.04.3 LTS on WSL2, Linux 6.6.87.2-microsoft-standard-WSL2 x86_64
Node: v24.18.0, npm 11.16.0
eve: 0.44.3, npm latest (also confirmed on 0.44.0 and 0.39.3)
microsandbox: 0.5.10, which is what eve declares as its peerOptional range (^0.5.0). Also confirmed on 0.6.13.
msb: 0.5.10, supplied by the npm dependency; no separate install
KVM: /dev/kvm present and read/write. Intel APICv is disabled by KVM policy on this host; microsandbox states APICv is optional and that sandbox correctness and isolation are unaffected.
Guest image: alpine, for busybox wget with HTTPS. eve's own default image behaves the same way; the outcome is decided by the network builder, not by the image.

Note on `eve info`: it prints "Could not resolve an eve agent root" in the reproduction directory. That is expected. The repository is not a scaffolded agent - it imports the published binding module directly. The reason is in Additional context.

Where does the bug occur?

Production (eve start / deployed)

Deployment

n/a

Build and runtime logs

npm test, three targets, one assertion set

  [FAIL] stock    pass=0 fail=3
  [pass] fixed    pass=3 fail=0
  [pass] control  pass=3 fail=0

STOCK - eve's applyMicrosandboxNetwork, unmodified
  placeholder exposed in the guest:  __EVE_MSB_SECRET_a47a260bb090c8552fc8b384__
  authorization seen by the origin:  __EVE_MSB_SECRET_a47a260bb090c8552fc8b384__
  not ok 1 - substitutes the real credential at the boundary
    expected: 'Bearer EVE-REPRO-REAL-VALUE-NOT-A-SECRET'
    actual:   '__EVE_MSB_SECRET_a47a260bb090c8552fc8b384__'

FIXED - the same call plus .tls((t) => t.interceptedPorts([443]).verifyUpstream(true))
  placeholder exposed in the guest:  __EVE_MSB_SECRET_a47a260bb090c8552fc8b384__
  authorization seen by the origin:  Bearer EVE-REPRO-REAL-VALUE-NOT-A-SECRET
  ok 1 - substitutes the real credential at the boundary

CONTROL - no eve: the same secret and the same interception, built directly
  placeholder exposed in the guest:  __CONTROL_PLACEHOLDER_NOT_A_SECRET__
  authorization seen by the origin:  Bearer EVE-REPRO-REAL-VALUE-NOT-A-SECRET
  ok 1 - substitutes the real credential at the boundary

The guest request exits 0 in every case. The origin returns 200. No error, no warning, no event.

The eve network plan the stock run applied, for reference:

  policy      -> default_egress deny, default_ingress deny
                 allow any:53 udp+tcp
                 allow domain postman-echo.com
  secret      -> env EVE_MSB_SECRET_A47A260BB090C8552FC8B384
                 placeholder __EVE_MSB_SECRET_a47a260bb090c8552fc8b384__
                 injectHeaders true, requireTlsIdentity true, allowHost postman-echo.com
  builder     -> trustHostCAs true
  tls         -> never configured

Suggested implementation prompt

Make the microsandbox backend's credential injection real: when a sandbox network policy carries
at least one transform-header rule, an HTTPS request from the guest to an allowed credentialed
host must arrive at the origin with the real header value, and the placeholder must never leave
the boundary.

The gap: applyMicrosandboxNetwork
(dist/src/execution/sandbox/bindings/microsandbox-network.js) registers each secret with
requireTlsIdentity(true) and sets trustHostCAs(true), but never configures TLS interception
on the network builder. Without interception the boundary cannot read or rewrite an HTTPS
request, so no secret ever matches - the guest's request goes through carrying the literal
placeholder, with no error and no event.

Constraints:

  • Enable interception only when the policy has at least one transform-header rule - the branch
    that already sets trustHostCAs(true). A policy with no credentials should not pay for
    interception.
  • The intercepted port list and the upstream-verification setting are your design calls. The
    reproduction uses interceptedPorts([443]) and verifyUpstream(true) only because they are
    the minimum that makes the boundary work; whatever you choose, this is a credential path, so
    upstream certificate verification should stay on by default.
  • Prefer loud over silent: if interception or a secret cannot be applied, fail the sandbox setup
    rather than sending the placeholder to the real origin.
  • Note for a wider fix: MicrosandboxSandboxCreateOptions and MicrosandboxSessionUseOptions
    expose no TLS hook today, so a consumer cannot work around this themselves. Exposing one is
    optional; fixing the default translation is the bug.

Acceptance criteria:

  • In https://github.com/tommy5dollar/eve-microsandbox-injection-repro, the stock target's three
    assertions go green against the fixed build: the origin receives the real value, the guest
    still sees only the placeholder, and the request succeeds. (Today stock is red and only the
    fixed/control targets pass; fixed is the same call plus one .tls(...).)
  • node substrate-checks.mjs still passes unchanged: per-host allow with deny-by-default egress
    holds, and "deny-all" still gives the guest no network interface.
  • A policy with no transform-header rules behaves exactly as before.
  • Separable follow-on, not required here: honouring transformHeaderRules[].match (method/path
    narrowing - see "The second thing that looks wrong" in Additional context). Fixing interception
    is what makes that gap observable; node match-drop.mjs fixed in the reproduction demonstrates
    it.

Additional context

Why "Production" rather than the shape of the repro. The reproduction runs on a local host,
but the damage lands on a deployed self-hosted instance using the microsandbox backend, where the
brokered credential is the whole point of the policy. Nothing about the failure is local-dev
specific: the same applyMicrosandboxNetwork runs on every path that creates or re-policies a
microsandbox VM.

Not a regression. dist/src/execution/sandbox/bindings/microsandbox-network.js is
byte-identical across eve 0.39.3, 0.44.0 and 0.44.3 (npm latest) -
sha256 cdb48530e5ba5877fb0e41dda5e5eaf57968194eb934fa7b00843993dcf84a80 - and the reproduction was
confirmed on all three. microsandbox-runtime.js and microsandbox-create.js are byte-identical
across the same versions too. No bisect is needed.

Why the repository imports dist/ instead of scaffolding with eve init. The defect sits
below anything the scaffold produces. Reaching it through a scaffolded agent needs a live model, a
credentialed host and a tool call before one line of sandbox configuration decides the outcome,
which buries the signal in a lot of unrelated machinery. The repository calls the published module
directly so the subject under test is eve's own shipped translation and nothing else.
lib/eve-binding.mjs is the only import seam, and it uses a relative path into node_modules
because eve's exports map does not expose dist/. As a side effect eve info reports no agent
root in that directory.

What this report is not claiming. The microsandbox backend is in good shape on the parts I
measured, and none of them are in question here. node substrate-checks.mjs in the repository
prints the first two:

  • Per-host allow rules and deny-by-default egress are enforced natively, at host grain, with no
    sidecar. Measured: with { allow: { "example.com": [] } }, https://example.com/ is REACHED
    and https://www.iana.org/ is BLOCKED.
  • "deny-all" maps to disableNetwork() rather than to a deny policy, so the guest gets no
    network interface at all. Measured: the only interface in the guest is lo. That is a stronger
    answer than a proxy that can be detached.
  • setNetworkPolicy recycles the VM through a snapshot - stop, snapshot, remove, recreate from
    the snapshot - and the workspace survives.
  • microsandbox itself substitutes a requireTlsIdentity secret correctly; variant 3 shows it.

The substrate is right. One builder call is missing from the translation.

The second thing that looks wrong, adjacent and separable - a different root cause with a
different fix, so I have not filed it as its own issue. createMicrosandboxNetworkPlan copies each
rule's match into the plan object; applyMicrosandboxNetwork never reads it. Only domain,
headers and placeholderHeaders are used, and the serialized policy carries the host alone. The
type's own documentation states the contract: "When provided, transforms and forwarding rules only
apply to requests that match every specified dimension."

node match-drop.mjs fixed authorises the credential on GET /get only, then sends POST /post
to the same host:

plan.transformHeaderRules[0].match = {"method":["GET"],"path":{"startsWith":"/get"}}

GET  /get   inside the rule's match    reached=true   authorization at the origin: Bearer EVE-REPRO-REAL-VALUE-NOT-A-SECRET
POST /post  outside the rule's match   reached=true   authorization at the origin: Bearer EVE-REPRO-REAL-VALUE-NOT-A-SECRET

So a match narrows nothing on this backend: the credential is injected on every request to the
host. On the stock binding this is invisible, because nothing is injected at all - fixing TLS
interception is what makes the gap reachable. Happy to file it separately if you would rather keep
the two apart.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions