Skip to main content
Netpause logo

Netpause under the hood

A pocket-sized Burp Suite living inside a Flutter app.

Intercept, inspect, and modify HTTP/S traffic on mobile devices — from inside the app itself.

A companion to the project's README.md — the "let me explain this over coffee" version. The README is the reference; this is the mental model to have in your head before you open lib/proxy_server.dart or lib/reverse_proxy_server.dart for the first time.

If you've ever pointed a browser at Burp Suite or mitmproxy, you already understand netpause conceptually. Put a proxy between a client and a server, let it read (or rewrite) the traffic in between. Netpause does exactly that, with one twist: the "client" and the "proxy" are the same app, running in the same process, on the same device. It's not intercepting your system's traffic — it's intercepting its own WebView's traffic, for one configured domain, driven by a YAML file of rewrite rules instead of a UI you click through by hand.

That's the whole idea. The interesting part — the part worth an actual write-up — is that "put a proxy in front of a WebView" means something completely different on Android than it does on iOS. Different enough that this project has two independent implementations of the same concept, in two different files, and neither is a thin platform-flag wrapper around the other. This is the story of both, told through the actual code.

Android: the version that works the way you'd expect

Android's WebView has a real, honest proxy override — ProxyController.setProxyOverride. Tell it "route everything through 127.0.0.1:8899," and it means it, including the HTTP CONNECT handshake a browser uses to tunnel HTTPS through a proxy. ProxyServer (lib/proxy_server.dart) is what's listening on the other end, and its very first decision, for every connection, is whether to actually look inside the traffic or just get out of the way:

if (line.method == 'CONNECT') {
  final connectHost = parts.first;
  // ...
  if (!profile.shouldMitm(connectHost)) {
    // Not our configured domain — pump bytes verbatim, both directions.
    // The proxy never decrypts this traffic at all.
    await _rawTunnel(socket, reader, connectHost, connectPort);
    return;
  }

  // Our domain — terminate TLS ourselves, using a cert we generated,
  // and start reading *new*, now-plaintext requests off this socket.
  active = await SecureSocket.secureServer(active, _tlsContext!, ...);
}

Two branches, and that second one is the entire trick behind every "MITM proxy" — Burp Suite included. Terminate the TLS handshake with a certificate you control, and the traffic isn't encrypted from your point of view anymore. Burp does this by asking you to install its root CA everywhere. Netpause does the narrow version: it tells the WebView, via onReceivedServerTrustAuthRequest, to trust one self-signed cert, for one domain, and nothing else — no device-wide CA, no effect on any other app or any other host this one talks to.

The detail that makes Android's whole story simple is that the WebView never finds out. Its address bar, its cookies, every absolute URL a page writes to itself — all of it still says the real domain the entire time, because from the WebView's point of view, it genuinely is talking to the real domain. The proxy is invisible in the most literal sense. Everything downstream of the CONNECT decision is just: read the request, check it against the configured rules, forward it (rewritten or not), write the response back.

iOS: no CONNECT, no override, two dead ends first

iOS's WebView has nothing equivalent to setProxyOverride with real teeth. This project tried two other doors before finding the one that opens, and both dead ends are worth knowing, because they explain a constraint the working mechanism has to live with.

First attempt — WKWebsiteDataStore.proxyConfigurations (iOS 17+). On paper this is precisely the right API: a WebView-scoped proxy override, the same shape as Android's. In practice, its CONNECT handshake happens inside CFNetwork's own internal proxy machinery, at a layer that never routes a "do you trust this certificate?" challenge to any navigation delegate. There's no hook to catch. Confirmed on a real device — the API looked exactly right and simply never called back.

Second attempt — a Network Extension "App Proxy" provider (NEAppProxyProvider), which intercepts network flows underneath the WebView entirely — closer to how a VPN client works than to a browser proxy. This one got fully implemented: a real Swift NEAppProxyProvider subclass, its own Xcode extension target, code reviewed and ready. It failed on-device with a system log line that has nothing to do with the code itself:

MDM must be used to create NEAppProxyProvider configurations

Some Network Extension provider types are gated behind enterprise device management. Not a missing entitlement, not a bug — a hard OS policy wall with no self-service path for a personal developer build.

Third attempt — the one that ships. If the WebView can't be convinced to transparently tunnel through a proxy, stop asking it to, and change what it's actually looking at. ReverseProxyServer (lib/reverse_proxy_server.dart) is a completely ordinary HttpServer.bindSecure on 127.0.0.1, presenting the same kind of self-signed cert Android uses. The WebView is simply told to navigate to https://127.0.0.1:<port>/ as its actual starting URL — a plain, first-party, unmediated navigation, which is the single most boring, best-supported self-signed-cert scenario iOS has. The trust challenge fires exactly the way you'd want, because from iOS's perspective nothing exotic is happening: you're just visiting a site with a certificate it doesn't fully trust yet.

That one design choice — the WebView's real, literal top-level origin is now 127.0.0.1, not the real domain — is the fact that explains almost every other line of iOS-specific code in this project.

One dedicated local port per real host

A real site rarely lives entirely on one hostname — a secondary flow on one subdomain, a widget served from another, sometimes an entirely separate third-party domain the flow legitimately crosses to and back. ReverseProxyServer gives each real host its own dedicated local port, bound the moment that host is first referenced:

Future<_Backend> _ensureBackendFor(String host, {int? portHint}) {
  return _backendsByHost.putIfAbsent(host, () async {
    final server = await _bindServer(0);       // ephemeral port
    final backend = _Backend(
      scheme: profile.backendScheme,
      host: host,
      port: portHint ?? profile.backendPort,
      localPort: server.port,
    );
    _listen(server, backend);
    return backend;
  });
}

Binding a whole new port per host, instead of sharing one port and tracking "which real host is this request actually for" some other way, is what makes routing trivial: the port a request arrived on answers that question directly. No session state, no path-prefix parsing, no lookup table that could ever drift out of sync with reality. Map.putIfAbsent here is also quietly doing something important — its check-and-insert is synchronous, so two requests discovering the same new host at the same instant can never race each other into binding two ports for it.

The problem this design creates, and the two fixes it needs

Once the browser's real origin is 127.0.0.1, every reference a page makes to any other host — its own alternate hostnames, or a genuinely external domain — points somewhere the proxy no longer controls. Click that link, and the browser leaves the proxy's control for the rest of the session, silently. This has to be fixed in two separate directions, because it's really two separate problems wearing the same coat.

Direction one: what comes back. A response body full of <a href="https://real-host/...">-style links has to be rewritten before it reaches the browser. response_rewriter.dart does this as a small, dependency-free regex pass:

final _absoluteUrlPattern = RegExp(
  r'(https?:)?//([a-zA-Z0-9.-]+)(?::(\d+))?' + r'''(?=[/?#"'\s]|$)''',
);

String rewriteHostReferences(String text, Map<String, int> hostToLocalPort) {
  return text.replaceAllMapped(_absoluteUrlPattern, (match) {
    final host = match.group(2)!;
    final localPort = hostToLocalPort[host];
    if (localPort == null) return match.group(0)!;   // not ours — leave it
    final schemePrefix = match.group(1);
    return schemePrefix == null
        ? '//127.0.0.1:$localPort'
        : 'https://127.0.0.1:$localPort';
  });
}

Same idea applies to a Set-Cookie's Domain= attribute (a cookie whose domain doesn't match the serving host gets rejected by the browser anyway, so stripping it just lets the cookie fall back to "whichever host actually served me" — now correctly 127.0.0.1), and to a Content-Security-Policy written for a world where the origin matches the real domain, which would otherwise block the very page it's meant to protect.

Worth calling out: this rewrite pass runs on any text-ish response, not just HTML. A JSON API response is exactly as capable of embedding a URL that a page's own JavaScript later reads and navigates to as an HTML page is — missing that meant some navigations escaped the proxy with no HTML link ever involved at all.

Direction two: what goes out. This is the one that's easy to miss entirely. Every outgoing request still carries a Referer and Origin saying https://127.0.0.1:8899 — which is, truthfully, exactly what page the WebView believes it's coming from. Most backend endpoints don't inspect those headers and never notice. Some do — validating Referer/Origin is a common, lightweight anti-forgery check — and a mismatch there can make an endpoint quietly fall back to some default behavior instead of erroring outright, which from the outside looks identical to a cookie or session bug. _rewriteOutgoingOrigin is the mirror image of the response-side rewrite, run in the opposite direction:

String? _rewriteOutgoingOrigin(String value) {
  final uri = Uri.tryParse(value);
  if (uri == null || uri.host != '127.0.0.1') return null;
  final realBackend = _backendsByLocalPort[uri.port];
  if (realBackend == null) return null;
  return uri
      .replace(scheme: realBackend.scheme, host: realBackend.host, port: realBackend.port)
      .toString();
}

Same underlying map (_backendsByLocalPort), just walked backwards: "this local port belongs to which real host" instead of "this real host belongs to which local port." Two directions, one root cause.

A small, genuinely interesting Dart gotcha

While chasing exactly this class of bug, one specific case turned out to have nothing to do with headers, cookies, or rewriting at all — a plain parsing quirk that's worth knowing about if you ever build anything on top of dart:io's HttpServer.

Some pages, for entirely mundane reasons — a template that concatenates a base URL already ending in / with a path that also starts with /, or a protocol-relative link built by string concatenation that forgets to trim a redundant slash — end up with an absolute URL containing a doubled slash right after the domain, like https://some-host//actual/path. A real browser doesn't care; it treats that as a harmless quirk and requests //actual/path as the path, same as always.

Dart's Uri class, however, follows the URL specification literally, and the spec says a path that starts with // is syntactically a "network-path reference" — the same shape you'd use to write //example.com/page to mean "same scheme, different host." Try it:

void main() {
  final u = Uri.parse('//actual/path');
  print(u.authority); // "actual"  <- read as a HOST, not a path segment
  print(u.path);      // "/path"  <- "actual" is silently gone
}

Feed a raw HTTP request line with that doubled slash into dart:io's HttpServer, and request.uri.path alone quietly loses the first real path segment the exact same way — because HttpServer builds request.uri with the same Uri parser. Route on request.uri.path without accounting for this, and a request meant for /actual/path is silently forwarded as /path instead. If the real backend has any kind of "unrecognized path → serve something generic" fallback (plenty do), you get a perfectly normal 200 OK full of completely unrelated content, and nothing in any log that says why.

The fix is small once you know to look for it: request.uri.authority is empty for every ordinary request — this ambiguity is the only time it isn't — so reconstructing the literal path whenever it's non-empty recovers exactly what was actually sent:

final rawPath = request.uri.authority.isEmpty
    ? request.uri.path
    : '//${request.uri.authority}${request.uri.path}';

The general lesson is the one worth keeping past this specific case: a mechanism that reparses raw HTTP through a general-purpose URI library can silently disagree with what a browser considers "the same request," in exactly the spots where a browser's own leniency papers over something the spec is stricter about. If a proxied request is producing suspiciously unrelated content instead of an outright error, check the literal path it actually forwarded before reaching for a more exotic theory.

The debugging habit that actually pays off here

Everything in the two sections above was found the same way: not by theorizing, but by capturing a request known to work — from a real browser's own network inspector, or just curl -v against the real backend directly — and diffing it, field by field, against what the proxy actually sent. Headers, cookies, body, and (as it turned out) the literal request path. It's a slower first step than guessing, and it's the one that actually finds the answer, because "everything looks correct" is exactly the situation where the bug is hiding in the one field nobody thought to check.

Where to go from here

lib/profile.dart and lib/rule_engine.dart are the genuinely shared, platform-agnostic half of this app — pure dart:io, no networking, no platform checks, trivially unit-testable. Read those first; both proxies build their rule dispatch on top of exactly the same functions. lib/proxy_server.dart and lib/reverse_proxy_server.dart are where each platform's actual story lives, and — deliberately — nothing keeps their rule-handling branches in sync automatically. If you add a new rule shape, you're adding it in both files, by hand, on purpose.

For the exact class names, function signatures, and the test files that pin every one of the behaviors above down so nobody has to rediscover them the hard way twice, the project's README.md is the real reference. Consider this page the version you'd want in your head before you open it.