Skip to content

icaptcha mock-consumed guard: swallowed dual-contact leak is invisible, and the blackhole has no tripwire test #211

Description

@beardthelion

crates/icaptcha-client/tests/icaptcha_mock_consumed.rs (#184) guards against a swallowed dual-contact leak with two
halves: mock-consumed asserts, plus a proxy blackhole (NO_PROXY for the mock's
host, 127.0.0.1,localhost, every proxy var pointed at a closed port) so a request
to any other origin fails instead of silently succeeding.

The blackhole half works as documented for leaks whose error propagates. Verified by
mutation at the #184 head:

  • cfg.url pointed at DEFAULT_URL with the blackhole armed: RED, failing in the
    blackholed tunnel (tunnel error ... Connection refused), exactly as the test's
    doc comment claims.
  • A dual-contact leak injected into obtain_proof (a stray call alongside the
    sanctioned flow: client.get(DEFAULT_URL).send().context("leak")? before the
    challenge request): RED on the injected call, aborting before the mock is
    reached. The green direction is inferred, not re-run: without the blackhole the
    request bypasses the proxy, and on a runner that can reach DEFAULT_URL it
    would succeed (send() does not error on non-2xx statuses), leaving the test
    green with the mock consumed. So the env setup is what makes this class fail.

Two gaps remain, both hardening rather than a current vulnerability:

  1. A swallowed leak stays green. Change the injected leak to
    let _ = client.get(DEFAULT_URL).send(); and the test passes: the mock is
    consumed and the leak's transport error is discarded. The closed-port design can
    only surface leaks whose failure propagates into the flow's result. Catching
    fire-and-forget exfiltration needs an observer rather than a dead end, e.g. a
    local listener standing in as the proxy that counts connection attempts, with the
    test asserting zero. Scope caveat: an observer only witnesses paths the test
    drives. The current test exercises a single passing round (200 on both endpoints,
    no PoW, no continue/failed branch), so a leak living on an error or retry branch
    makes no connection during the run and a zero-count assertion stays green. An
    observer design should either drive those branches too or state that coverage is
    limited to the exercised path, and its guarantee covers only proxy-honoring
    transports. Candidate direction, not a vetted design.

  2. The blackhole mechanism has no committed tripwire. obtain_proof builds a
    stock reqwest::blocking::Client, which is why the proxy env binds its transport.
    If the builder later gains .no_proxy() or a custom connector, the guard disarms
    silently and every existing run stays green. A committed negative case should lock
    the mechanism (a test asserting that a non-loopback cfg.url fails while the
    blackhole is armed). Design note: the naive versions are wrong in one of two ways.
    Pointing at an unresolvable host keeps the request failing even when the guard is
    disarmed, so a tripwire asserting failure stays green vacuously (a DNS failure
    masquerades as the blackhole working), and pointing at a real host makes a live
    network call the moment the guard disarms, degrading into the same masquerade on
    any runner that cannot reach that host (the connect error passes for the
    blackhole working). A robust tripwire needs a destination that would succeed if
    and only if the blackhole is not applied, e.g. a local listener on a loopback
    alias that NO_PROXY does not cover; note this depends on reqwest's NO_PROXY
    matching by exact IP (holds in the pinned reqwest/hyper-util, recheck on
    upgrade) and on the OS routing 127/8 aliases (default on Linux, macOS needs an
    explicit alias). Also a candidate direction, not a vetted design.

Neither gap blocks #184, which is why this is an issue and not a review comment.

Metadata

Metadata

Assignees

No one assigned

    Labels

    kind:securityVulnerability fix or hardeningsev:highMajor break or real security/trust risk, no easy workaroundsubsystem:identityDID/UCAN, http-sig auth, push authorization

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions