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 configurationsSome 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.
