A Flutter app that browses a configured website through a local, loopback-only proxy running inside the app itself. The proxy can rewrite that site's own requests before they reach it — stripping a tracking parameter, redirecting one path to another, or merging fields from a request into a different one — driven entirely by a YAML config file, with no rewrite rules built into the app itself.
It supports any number of independent targets ("profiles"): pick one on the app's first screen, and everything after that — the proxy, its TLS identity, its rules — is scoped to that profile alone.
The app doesn't just embed a WebView pointed at a URL. It runs a local
HTTPS proxy on 127.0.0.1 and gets the WebView's traffic routed through
it, so it can see and rewrite the site's requests before they leave the
device. How that routing happens is where Android and iOS genuinely
diverge — not as a platform-detection if, but as two structurally
different mechanisms living in two different files, because Android and
iOS expose fundamentally different capabilities for this:
- Android (
lib/proxy_server.dart) can install itself as the WebView's actual proxy (ProxyController.setProxyOverride) and transparently intercept aCONNECTtunnel. The WebView still believes it's talking to the real domain the whole time; the proxy is invisible to it. - iOS has no public, non-MDM API for that.
lib/reverse_proxy_server.darttakes a different approach: the WebView is told to navigate directly to the proxy's own address, and the proxy rewrites every response so the page keeps working under that new address instead of the real one.
Everything else — the profile config, the rule-matching engine, the raw
HTTP parsing, the TLS certificates, the client that talks to the real
backend — is shared, unchanged, dart:io-only code with no platform
branching in it at all. That shared core is Part 1 below; Android's
mechanism is Part 2; iOS's is Part 3. If you're modifying rule behavior,
you want Part 1. If you're modifying how interception itself works on one
platform, you want Part 2 or Part 3 — they don't affect each other.
Every file in this part is plain dart:io and Flutter, with no reference
to Platform.isIOS/Platform.isAndroid anywhere inside it. Both
ProxyServer (Android) and ReverseProxyServer (iOS) are built entirely
on top of this layer.
Entry point. runApp(const NetpauseApp()); NetpauseApp is a
StatelessWidget wrapping MaterialApp(title: 'netpause', home: ProfileSelectScreen()). Nothing else lives here — app-wide state (which
profile, proxy lifecycle) is scoped to the screens that need it, not
global.
The configuration model, and the only file that knows about
assets/profiles.yaml.
InterceptRule— one rewrite rule. Matched bytrigger(aRegExpagainst the request path), then either:- GET-style path-swap:
pathReplace/pathReplaceWith— forward the request completely unchanged (method, headers, body, query string) except for a regex-based swap in the path.pathReplaceis itself aRegExp(seeapplyPathReplaceinrule_engine.dart), soreplaceFirstonly touches the matched substring — anything else, including a trailing query string, passes through untouched. - POST-style body-merge:
extractors(named regexes run against the request's own body) + optionalinjectTimestampAs(adds the current timestamp to the extracted values under a named key) +bodyTemplate(fills{{name}}placeholders with those values) +destinationPath(where the merged request goes). - Script-injection:
injectScript— forward the request unchanged, then run the given JS snippet before the page's own scripts execute. Implemented differently per platform:- iOS (reverse-proxy path): the script is prepended as an inline
<script>tag into the HTML response body (gated on whether the body actually starts with<— see Part 3). - Android (CONNECT-tunnel path): the script is registered as an
AT_DOCUMENT_STARTUserScriptin the WebView, so it runs before any page script on every load._runInjectScriptsinbrowser_screen.dartalso re-evaluates it viaevaluateJavascriptafter eachonLoadStop, respecting thetriggerregex (sinceUserScripthas no per-URL filter on Android — it fires on all pages). Exists to paper over a target site's own latent client-side race that this proxy's added latency can expose — see "WhyinjectScriptexists" in Part 3 before reaching for it.
- iOS (reverse-proxy path): the script is prepended as an inline
- GET-style path-swap:
Profile— one target's full config:name,backendScheme/backendHost/backendPort(the real site),startPath+startUrl(what the WebView loads on launch),mitmDomain/shouldMitm(host)(which hosts get TLS-terminated vs. blind-tunneled on Android, or auto-discovered vs. left alone on iOS — the same predicate, two different consumers),proxyPort(defaults todefaultProxyPortif omitted), andrules.externalHost/externalScheme/externalPort— all optional (externalHostdefaults tonull, meaning "not configured";externalSchemedefaults to"https";externalPortdefaults to443). These exist only forReverseProxyServer(see Part 3) — a single, known-in-advance external domain the flow legitimately crosses to and back — some other service on a completely different domain thanmitmDomain. Android's transparent tunnel never needs this: the WebView's own address bar always shows the real domain no matter which site it's currently on, so crossing to an unrelated domain and back "just works" with zero configuration. iOS's mechanism can't assume that — see Part 3 for why.
loadProfiles()— readsassets/profiles.yamlviarootBundleandpackage:yaml, returnsList<Profile>.
Both Profile and InterceptRule have a .fromYaml(YamlMap) factory;
there's no other way to construct a Profile in normal use, which keeps
"what profiles exist" entirely data-driven.
The app's first screen. Calls loadProfiles(), shows a list (name,
backend host, rule count), and pushes BrowserScreen(profile: ...) for
whichever one is tapped. Shows the YAML parse error inline if
profiles.yaml is malformed, rather than crashing.
Pure functions, no networking, no state — this is what makes it trivial to
unit test (see test/rule_engine_test.dart) and safe to reason about in
isolation. Both ProxyServer and ReverseProxyServer call these same
functions:
matchRule(path, rules)→ the firstInterceptRulewhosetriggermatches, ornull.extractValues(body, extractors)→ runs each named regex againstbody; returnsnull(not a partial map) if any required capture group is missing, so callers can treat "some values missing" the same as "nothing extracted."fillTemplate(template, values)→ replaces every{{key}}intemplatewithvalues[key].formatTimestamp(dt)/pad2(n)→ ayyyyMMddHHmmssformatter, available tobodyTemplates viainjectTimestampAs.
Two plain data classes, no logic: ParsedRequest (method, path, headers,
body) and BufferedResponse (status code, reason phrase, headers, body —
fully buffered, not streamed, since rules need to inspect a complete body
before deciding what to do).
The byte-level work that turns a raw socket stream into the data classes
above — used by Android's ProxyServer directly (it parses raw sockets
itself); iOS's ReverseProxyServer doesn't need it, since dart:io's
HttpServer already parses HTTP for it (see Part 3 for why that
convenience comes with its own sharp edge).
ByteStreamReader— buffers bytes off aStream<List<int>>and exposesreadLine(),readExactly(n), andreadChunk()on top of that buffer.readChunk()exists specifically soproxy_server.dartcan pump raw bytes for a tunnelled (non-MITM'd) connection without creating a second subscription on what's a single-subscription socket stream.readRequestLine/readHeaders/readBody(the last delegating to_readChunkedBodywhenTransfer-Encoding: chunkedis present) build request pieces on top ofByteStreamReader.extractPath(target)— normalizes a request target down to justpath?query, stripping scheme/host if the target was an absolute URL (as it is for plain-HTTP proxy requests).
TlsIdentity.loadForDomain(mitmDomain) reads
assets/certs/<mitmDomain>_cert.pem/_key.pem and builds a
SecurityContext — the same call, unchanged, is used by both ProxyServer
(Android) and ReverseProxyServer (iOS) to present when it terminates
TLS. Throws a StateError naming the missing domain and the exact
buildCerts.sh command to fix it if those files don't exist — this
surfaces as an inline error in BrowserScreen instead of a crash.
UpstreamClient.send(...) forwards a (possibly rewritten) request to the
real backend via HttpClient, forcing Accept-Encoding: identity so the
proxy always gets an uncompressed, directly-forwardable body, buffers the
full response, and returns it as a BufferedResponse. scheme is an
explicit parameter (not a global), since different profiles can target
different schemes. It also explicitly overrides the outgoing Host
header to $host:$port (the real backend's own host, not whatever the
client originally sent) — every caller relies on this, and it's the
reason a bare Host mismatch was never the iOS bug described in Part 3
(the fix needed there was one level up, for Referer/Origin, which this
file deliberately leaves untouched since it has no way to know what the
right value should be — that's the caller's job).
Both ProxyServer and ReverseProxyServer construct their own
UpstreamClient, but for different lifetimes — see each part for why.
Everything a profile needs lives in assets/profiles.yaml:
profiles:
- name: Example
backendScheme: https
backendHost: example.com
backendPort: 443
startPath: /
mitmDomain: example.com
# proxyPort is optional (defaults to 8899) — only set it per-profile
# if you need more than one profile's proxy running at once.
rules: []
# externalHost/externalScheme/externalPort are optional and iOS-only
# (see Part 3) — omit entirely if the flow never crosses to a
# separate external domain, or if you only need Android.A rule entry can use any of the four shapes below (see InterceptRule above
for what each field does):
rules:
# GET: forward unchanged except a regex-based path swap
- trigger: '^/testURL\.php'
pathReplace: "userId=\\d+"
pathReplaceWith: "userId=123"
# POST: extract from the request's own body, optionally inject a
# timestamp, fill a template, forward the merged request elsewhere
- trigger: '^/api/v1/submit'
destinationPath: "/api/v1/submit"
injectTimestampAs: "Request_DateTime"
extractors:
UserId: 'UserId=([^&]*)'
bodyTemplate: "UserId={{UserId}}&Request_DateTime={{Request_DateTime}}"
# DOM automation: wait for a CSS selector to appear in the live DOM,
# then fill input values and optionally click an element — all run
# entirely inside the WebView via evaluateJavascript, independent of
# the proxy layer. trigger is optional (omit to fire on any page).
- htmlAutomation:
triggerHTML: 'input[name="field1"]'
injectValues:
'input[name="field1"]': "field 1 value"
'input[name="field2"]': "field 2 value"
action: '#submit-button'
# Script-injection: forward unchanged, run a JS snippet before the
# page's own scripts — a testing aid for a target site's own timing
# bug, not a permanent fix (see Part 3, "Why injectScript exists").
# Works on both iOS and Android (see InterceptRule above for how each
# platform handles it).
- trigger: '^/app\.html'
injectScript: |
window.someThirdPartyGlobal = window.someThirdPartyGlobal || {stub: true};Add a new target site by adding a new list entry — no code changes needed. The profile-select screen and both proxies pick it up automatically.
scripts/buildCerts.sh -u <domain> # generate one domain's cert/key pair
scripts/buildCerts.sh --all # generate one for every profile's
# mitmDomain in assets/profiles.yamlThis writes assets/certs/<domain>_cert.pem/_key.pem (gitignored, never
committed — regenerate them locally before building) using:
openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \
-keyout "assets/certs/${domain}_key.pem" \
-out "assets/certs/${domain}_cert.pem" \
-subj "/CN=${domain}"TlsIdentity.loadForDomain will throw a clear error at app startup if a
selected profile's certs don't exist — run buildCerts.sh first. Both
platforms need this step; Android additionally needs the
network_security_config.xml step covered in Part 2.
flutter analyze
flutter testtest/rule_engine_test.dart—rule_engine.dart's pure functions,Profile.fromYaml/InterceptRule.fromYamlparsing (both rule shapes, and thedefaultProxyPortfallback), and thatloadProfiles()actually parses the shippedassets/profiles.yaml. Cross-platform.test/response_rewriter_test.dart— the pure rewrite helpers used byReverseProxyServer(Part 3). iOS-only, but tested with no WebView, no networking, nodart:ioat all.test/reverse_proxy_server_test.dart— end-to-endReverseProxyServertests against real, hermetic local sockets (tester.runAsync+HttpOverrides.global = null, sincetestWidgetsotherwise runs in aFakeAsynczone that blocks real socket I/O and installs anHttpOverridesthat short-circuitsHttpClient). iOS-only. This is also where every bug described in Part 3 has a regression test.
Android's WebView (via Chromium's networking stack) can be told to route
all its traffic through an arbitrary proxy
(ProxyController.instance().setProxyOverride) — including https://
traffic, via the standard HTTP CONNECT mechanism every TLS-tunnelling
proxy uses. lib/proxy_server.dart is that proxy. Critically, the WebView
never finds out: it still navigates to and displays the real domain the
whole time. The proxy sits underneath, transparently, deciding per-CONNECT
whether to actually look inside the traffic:
- Not the configured domain → blind raw-TCP tunnel. The proxy never decrypts this traffic; it just pumps bytes in both directions. Real TLS reaches the real origin, untouched.
- The configured domain (
profile.mitmDomain, or a subdomain of it) → the proxy terminates TLS itself, using a self-signed certificate for that exact domain (see TLS: MITM-ing your own WebView traffic below), so it can read the (now-plaintext) HTTP requests and responses.
This is why Android never needs anything like the externalHost config
field or the request/response rewriting Part 3 is full of: the browser's
own understanding of "what domain am I on" is completely undisturbed by
this mechanism, so a site's own absolute URLs, cookies, Referer, CSP —
all of it — are already correct with zero help from the proxy.
flowchart TB
SEL["ProfileSelectScreen<br/>pick a Profile"]
subgraph UI["browser_screen.dart"]
WV["InAppWebView<br/>(proxied via ProxyController)"]
end
subgraph Proxy["proxy_server.dart"]
PS["ProxyServer(profile)<br/>127.0.0.1:profile.proxyPort"]
end
subgraph Parsing["request_parser.dart"]
RP["ByteStreamReader →<br/>readRequestLine / readHeaders / readBody"]
end
subgraph Msg["http_message.dart"]
MS["ParsedRequest / BufferedResponse"]
end
subgraph Engine["rule_engine.dart"]
RE["matchRule / extractValues / fillTemplate"]
end
subgraph Upstream["upstream_client.dart"]
UC["HttpClient → real backend"]
end
TLS["tls_identity.dart<br/>self-signed cert for<br/>profile.mitmDomain"]
CFG["profile.dart<br/>Profile: backendHost/Port/Scheme,<br/>proxyPort, rules, shouldMitm()<br/>loaded from assets/profiles.yaml"]
SEL -- "Profile" --> WV
WV -- "CONNECT / HTTP" --> PS
PS -- "shouldMitm(host)? terminate TLS" --> TLS
PS --> RP --> MS
RP -- "path" --> RE
RE -- "matched ⇒ transformed<br/>no match ⇒ passthrough<br/>error ⇒ fallback" --> PS
PS --> UC
UC -- "response" --> PS
PS -- "response" --> WV
CFG -.-> SEL
CFG -.-> PS
CFG -.-> RE
NOT["Non-matching host<br/>(shouldMitm = false)"] -. "raw TCP tunnel,<br/>bytes untouched" .-> PS
The biggest file in the Android path, and the one most worth reading start
to end if you're extending Android's interception. ProxyServer(profile)
binds a ServerSocket on 127.0.0.1:profile.proxyPort. Per connection:
- Read the first request line (via
request_parser.dart, working directly on the raw socket — this file predatesdart:io'sHttpServerbeing used anywhere in this app). If it'sCONNECT host:port:!profile.shouldMitm(host)→ respond200 Connection Established, then_rawTunnelbytes verbatim in both directions until either side closes. The proxy never looks inside this traffic.profile.shouldMitm(host)→ respond200 Connection Established, thenSecureSocket.secureServerthe connection using the cert fromTlsIdentity, and start reading new requests off that now-decrypted socket._serveforwards to the samehost:portthe clientCONNECT'd to — notprofile.backendHost/backendPort— since a backend can live on a non-default port and hardcoding one would send traffic to the wrong place.- Not
CONNECT(plain HTTP, absolute-form request line) → forward straight toprofile.backendHost/backendPort.
_handleRequestruns the rule engine against the request path:- No rule matches → forward unchanged, emit
ProxyEventKind.passthrough. pathReplacerule → swap the regex match in the path, forward, emit.transformed.- Body-merge rule → decode the body as UTF-8,
extractValues; if extraction fails,_forwardOriginal(unchanged request, emit.fallback); otherwise merge in the optional timestamp,fillTemplate, forward the merged request todestinationPath, emit.transformed. If that forward throws, also fall back to_forwardOriginal(.fallback) rather than dropping the connection.
- No rule matches → forward unchanged, emit
_writeResponsewrites the upstream's status line, headers (droppingtransfer-encoding/content-length/content-encoding/connection, which get recomputed), and body back onto the client socket, thenconnection: closes — every request on the MITM'd socket effectively gets its own upstreamUpstreamClient()and its own response write, with no keep-alive on the wire back to the WebView.
ProxyEvents stream out via events for browser_screen.dart to turn
into SnackBars.
BrowserScreen's initState branches on Platform.isIOS — everything in
this subsection is the else branch. _initProxy():
- Starts a
ProxyServer(profile)and gets back the port it bound. - Calls
ProxyController.instance().setProxyOverride(settings: ProxySettings(proxyRules: [ProxyRule(url: '127.0.0.1:$port')]))— this is the actual "make the WebView use this proxy" call, and it's Android-only; there is no iOS equivalent, which is the entire reason Part 3 exists as a structurally different mechanism rather than a platform-conditional tweak to this one. - Subscribes to
_proxyServer.eventsto showSnackBars. - Once the port is known, builds the
InAppWebViewwithinitialUrlRequest: URLRequest(url: WebUri(profile.startUrl))— the real backend URL, since the WebView's own address bar is expected to show the real domain the whole time.
onReceivedServerTrustAuthRequest (shared code, but exercised differently
per platform — see Part 3 for iOS's branch) PROCEEDs only for
profile.shouldMitm(host) hosts on Android, CANCELs (normal browser
behavior) for everything else — this is the app-level half of trusting the
proxy's self-signed cert; network_security_config.xml below is the
WebView/Chromium-networking-stack half.
To intercept a profile's https:// traffic at all, the proxy has to
present something when the WebView does its TLS handshake — and it
presents a certificate it generated for itself. Two things keep this
narrowly scoped to "the app inspecting its own traffic," not a general
interception tool:
- Each certificate is self-signed, generated locally, and loaded only
into this app's own
dart:ioSecurityContext— it's never installed as a device-wide trusted CA, and it never leaves the device. - Trust for it is scoped two ways:
Profile.shouldMitm(host)at the app level (browser_screen.dart'sonReceivedServerTrustAuthRequest), and a matching per-domain<domain-config>innetwork_security_config.xmlat the Android/Chromium level (see below) — both restricted to exactly that profile'smitmDomain(+ subdomains). Every other host the device talks to is unaffected.
scripts/buildCerts.sh copies each domain's public cert only (never
the private key) into android/app/src/main/res/raw/<slug>_cert.pem, and
regenerates android/app/src/main/res/xml/network_security_config.xml
with one <domain-config> per domain that has a raw cert present:
<network-security-config>
<domain-config>
<domain includeSubdomains="true">example.com</domain>
<trust-anchors>
<certificates src="@raw/example_com_cert" />
</trust-anchors>
</domain-config>
</network-security-config>This is what makes Android's WebView/Chromium networking stack actually
accept the proxy's certificate for that domain — without it, TLS
interception generally only works via the app-level
onReceivedServerTrustAuthRequest override, which is less reliable across
WebView versions. AndroidManifest.xml wires it in via
android:networkSecurityConfig="@xml/network_security_config". The
checked-in default is an empty <network-security-config/> so the project
still builds before you've run buildCerts.sh. This file has no iOS
equivalent — iOS's mechanism (Part 3) never needs Chromium/WebKit to trust
a certificate for a domain it isn't actually connecting to.
flutter pub get
scripts/buildCerts.sh --all
flutter build apk --debug # or: flutter runAdd a new kind of rule — InterceptRule currently supports three
proxy-layer rule shapes (path-swap, body-merge, injectScript) plus DOM
automation (htmlAutomation). To add a fourth proxy-layer shape:
- Add the new fields to
InterceptRuleand itsfromYamlfactory inlib/profile.dart(Part 1). - Add a branch for it in
proxy_server.dart's_handleRequest, after thematchRulecall — follow the existingif (rule.pathReplace != null) { ... }branch as a template: forward viaUpstreamClient.send, emit aProxyEvent, call_writeResponse. If you want the same rule shape to work on iOS too, make the equivalent edit inreverse_proxy_server.dart's_handleRequest(Part 3) — the two files' rule-dispatch logic intentionally mirrors each other, but nothing enforces that mechanically; a new branch added to one doesn't automatically appear in the other. - Add coverage in
test/rule_engine_test.dartif the new shape adds logic torule_engine.dartitself (rather than just toproxy_server.dart's orchestration).
Note on
injectScript: this rule shape is already fully supported on Android —BrowserScreenregisters eachinjectScriptrule as anAT_DOCUMENT_STARTUserScriptand re-evaluates it on everyonLoadStop. No changes toproxy_server.dartare needed; adding a newinjectScriptrule toprofiles.yamlis sufficient.
Add a response-side rule (rewriting what comes back from the
backend, not just the request) — currently proxy_server.dart's
_handleRequest only rewrites requests; the response from
UpstreamClient.send is forwarded as-is by _writeResponse. You'd add a
hook between client.send(...) returning and _writeResponse being
called, likely keyed off the same matched rule or a new InterceptRule
field. (iOS's ReverseProxyServer already has a response-rewrite pass for
every request, not just matched ones — see Part 3 — so this asymmetry is
worth keeping in mind if you're trying to keep both platforms' behavior
equivalent.)
Add a second simultaneous profile (browsing two profiles at once) —
Profile.proxyPort already supports this (set distinct ports per profile
in profiles.yaml); BrowserScreen would need to stop assuming it's the
only screen with a live ProxyServer — currently each BrowserScreen
owns and disposes its own, which already supports multiple sequential
sessions but hasn't been exercised with two running concurrently.
Debugging a connection — every stage logs with the [proxy] tag via
plain print(); flutter run and watch the console. ProxyEventKind
(passthrough/transformed/fallback) is the quickest signal for "did a
rule fire, and did it work" without reading logs — it's surfaced as a
SnackBar in the running app already.
Two other iOS approaches were tried and abandoned before landing on the current one — both confirmed non-functional or infeasible via real on-device testing, not assumption:
WKWebsiteDataStore.proxyConfigurations(iOS 17+, transparent CONNECT-tunnel proxying — the same shape as Android's mechanism, ported to iOS's newer API): the CONNECT-tunnel's TLS handshake happens inside CFNetwork's own proxy machinery, at a layer where the server-trust challenge is never routed to any navigation delegate at all. There's no hook to make the WebView trust a self-signed cert for the intercepted domain.NEAppProxyProvider(a Network Extension "App Proxy" target, flow-level interception below the WebView): fully implemented and code-reviewed, but on-device activation failed with iOS's own system log message "MDM must be used to create NEAppProxyProvider configurations" — a hard OS policy gate on this provider type, unrelated to any app code, that has no self-service workaround.
The mechanism that actually works: a local Dart reverse proxy.
ReverseProxyServer (lib/reverse_proxy_server.dart) binds a real HTTPS
server on 127.0.0.1, using the profile's self-signed MITM identity
(TlsIdentity.loadForDomain, unchanged from Android's usage — Part 1).
The WebView navigates directly to https://127.0.0.1:<port>/, not the
real backend — a literal, unmediated direct navigation, the single most
standard self-signed-cert scenario iOS supports. Unlike both abandoned
mechanisms, onReceivedServerTrustAuthRequest genuinely receives this
challenge (see browser_screen.dart on iOS, below).
This is a fundamentally different shape of problem than Android's. On
Android, the WebView's own understanding of "what domain am I on" is
never touched — it's still looking at the real domain the whole time, so
every reference the site makes to itself, every cookie, every Referer
header, is already correct by construction. On iOS, the WebView's actual
top-level origin is 127.0.0.1. That one fact is the root cause of
almost everything else in this section: any reference to any other
domain — the site's own alternate hostnames, or a genuinely external
domain the flow legitimately crosses to — has to be explicitly
rewritten to stay under 127.0.0.1, in both directions, or the
browser silently and permanently leaves the proxy's control.
Every real host this profile's traffic can legitimately involve gets its own dedicated local port, bound the first time that host is actually referenced:
profile.backendHostand (if configured)profile.externalHostare bound up front instart().- Any other subdomain of
profile.mitmDomain— a real site routinely uses more than the one host it was originally configured with, e.g. a separate feature area on its own sub-site, or a dedicated subdomain for one specific flow — is discovered and bound lazily, the first time a response rewrite encounters a reference to it (_ensureBackendFor).
Binding a whole new port per host, rather than sharing one port across
all of them and trying to track "which real host is this for" some other
way, is what lets _handleRequest know unambiguously which real backend
an incoming request is actually for: the port it arrived on says so
directly. No session state, no path-prefix parsing, no per-request
lookup table that could drift out of sync. Each bound port is wrapped in a
_Backend (scheme, host, port, localPort) and stored in two maps:
_backendsByHost (for finding/creating a backend given a real hostname)
and _backendsByLocalPort (the reverse lookup — see "Two rewrite
directions" below for why that's needed too).
_ensureBackendFor uses Map.putIfAbsent, which is safe under concurrent
callers specifically because the check-and-insert is synchronous — there's
no await between "is this host already bound" and "bind it" — so two
requests discovering the same new host at the same moment never
double-bind a port for it.
A host that satisfies neither profile.backendHost, profile.shouldMitm,
nor profile.externalHost (_belongsToThisProfile) — a genuinely
unrelated third party like analytics, a CDN, or fonts — is never rewritten
at all, and reaches the WebView as an ordinary direct connection, exactly
as it would on Android.
Structurally this mirrors proxy_server.dart's rule dispatch almost
exactly (same matchRule/pathReplace/body-merge branches, same
ProxyEventKinds) — the difference is everything around it, covered in
the subsections below. One thing worth calling out on its own, because it
cost real debugging time to find:
Gotcha:
request.uri.pathcan silently drop a path segment.dart:io'sHttpServerparses the raw request line into aUrifor you, which is normally a convenience — until a site's own HTML writes an absolute URL with a doubled slash before the path (this app has actually hitaction="https://realsite.example//cc/submit.php"in the wild — cosmetic to a real browser, which treats the doubled/as harmless). Dart'sUriparser follows RFC 3986's syntax literally: a path starting with//is a network-path reference, so it reads the segment right after those two slashes as an authority (host) component, not as the first real path segment.request.uri.pathalone then silently returns the path without that segment — you forward to the wrong real path, the real backend's own routing doesn't recognize it, and — if that backend has a catch-all "unknown path → serve the homepage" fallback (many do) — you get a normal-looking200 OKwith completely unrelated content and no error anywhere to point at the real cause.The fix, in
_handleRequest: checkrequest.uri.authority— empty for every ordinary request, non-empty only in this exact ambiguous case — and if it's non-empty, reconstruct the literal path as'//${request.uri.authority}${request.uri.path}'before using it for anything (routing, rule matching, or forwarding). Verified against a realHttpServer, not justUri.parsein isolation, and covered by a regression test intest/reverse_proxy_server_test.dart.proxy_server.dartnever hits this at all — it parses request lines itself, byte-for-byte, viarequest_parser.dart, and never routes the raw target throughUriin a way that re-interprets it.
Pure, dependency-free string-rewriting helpers — no dart:io, which is
why test/response_rewriter_test.dart can test them with no networking at
all.
findAbsoluteUrlHosts(text)— finds everyhttps://host,http://host, and protocol-relative//hostreference intext(with or without an explicit port), returning each as aHostReference(host, port). Doesn't modify anything —ReverseProxyServer._rewriteTextcalls this first to discover which hosts a response actually references, decides which ones belong to this profile (_belongsToThisProfile) and asynchronously ensures a backend/port exists for each (this two-phase scan-then-replace split exists becauseString.replaceAllMapped's callback must be synchronous, but binding a new port is async).rewriteHostReferences(text, hostToLocalPort)— the actual replacement pass: every reference to a host present in the map gets rewritten to127.0.0.1:<that host's local port>, preserving whichever form (https:///http:///protocol-relative) the original used — both schemes upgrade tohttps, since the local server only ever speaks HTTPS; protocol-relative stays protocol-relative. A host not in the map is left completely untouched.rewriteSetCookieDomain(setCookieValue)— strips theDomain=attribute from aSet-Cookievalue. A cookie whoseDomaindoesn't match the serving host is silently rejected by the browser per RFC 6265 anyway, and once responses are served from127.0.0.1instead of the real domain, every configuredDomainis now wrong — stripping it lets the cookie fall back to host-only scope for whichever host actually served it (every other attribute —Secure,SameSite,Path,Max-Age,HttpOnly— is left untouched).stripRestrictiveHeaders(headers)— removesContent-Security-Policy/Content-Security-Policy-Report-Only/Access-Control-Allow-Origin. A CSP written for the real domain would disallow scripts/connections now pointed at127.0.0.1; a narrow CORS allow-list would reject requests from the new origin.
The response-side rewriting above (real host → 127.0.0.1) is necessary
but not sufficient. It solves "does the page keep working when it
references another host." It does nothing for the opposite problem: once
the WebView's own top-level origin is 127.0.0.1, every outgoing
request also tells the real backend the wrong thing about where it came
from.
UpstreamClient (Part 1) already handles the most obvious case — it
explicitly overrides the outgoing Host header to the real backend's own
host, not whatever the client sent. But Referer and Origin are
different: those describe the page that made the request, and nothing
downstream of _handleRequest has enough context to know what the "real"
value should have been — only ReverseProxyServer does, since only it
knows which local port maps to which real backend.
_rewriteOutgoingOrigin(value) is the reverse of _rewriteText: given a
header value that's a 127.0.0.1:<port> URL, it looks up
_backendsByLocalPort[port] and rewrites it back to that backend's real
scheme://host:port, applied to every outgoing Referer/Origin header
in _handleRequest before forwarding. Without this, the real backend sees
every request as if it originated from 127.0.0.1 instead of its own
domain — most endpoints don't care, but one that validates Referer/
Origin as a basic anti-forgery check can silently reject the
mismatch and fall back to its own default page instead of erroring
outright. That failure mode is
indistinguishable from the outside from a session or cookie bug — it
was, in practice, the single hardest bug in this mechanism to isolate,
precisely because every individual response along the way looked
completely normal.
_writeRewritten's isRewritableText check decides which responses get
the _rewriteText pass applied to their body at all — it has to be an
allowlist, not "rewrite everything," because the decode-as-UTF8 /
re-encode round trip is lossy for genuinely binary bytes (an invalid
UTF-8 sequence gets replaced with U+FFFD on decode, which re-encodes to
different bytes than the original — silently corrupting an image or font
response that happened to match nothing in the regex). Guessing wrong
here has to fail by under-rewriting, never by corrupting a binary
response.
The allowlist is text/*, javascript, json, and xml — deliberately
broader than "the obvious page-asset types" (text/html, text/css,
plain JS). A REST API response is exactly as capable of embedding a URL
that later drives navigation as an HTML page is: a JSON response like
{"redirectUrl": "https://example.com/next"}, read by the page's own JS
and assigned to window.location, needs that URL rewritten just as much
as a literal <a href="..."> does — and it was, in fact, a real bug that
this allowlist originally only covered text/html/javascript/text/css
and missed exactly this case.
ReverseProxyServer holds a single _upstreamClient for its entire
lifetime (closed once, in stop()), rather than constructing a fresh
UpstreamClient() per request the way proxy_server.dart does. A fresh
UpstreamClient() means a fresh HttpClient(), and thus a fresh TCP+TLS
handshake to the real backend for every single request — even a
34-byte image. A direct connection (or Android's CONNECT-tunnel, which
never terminates TLS at all) reuses one connection via ordinary HTTP
keep-alive; paying a full handshake per request adds real, avoidable
latency. That latency was, at one point during this mechanism's
development, plausibly enough to lose a client-side timing race a real
site depended on (a follow-up request assuming an earlier one had already
completed) — reusing one client removes that risk across the board, not
just for the one request where it was noticed.
This proxy's added latency isn't just an Android-vs-iOS timing footnote
(see "Why UpstreamClient is one shared instance" above) — it can also
expose a latent race in the target site's own code that a direct,
unproxied connection usually wins by luck. A pattern this project has hit
in practice: a page's own script references a global provided by a
third-party <script async defer> tag (an identity/SSO widget, an
analytics SDK, anything loaded off another domain) with no check that
it's actually defined yet. On a direct connection, the third-party
script's own request usually finishes first. Under this proxy, every
first-party request (the page, its own JS, its own API calls) pays one
extra local hop — sometimes just enough to flip that race and turn
"usually fine" into "reliably throws": an uncaught ReferenceError deep
in a callback the page never expects to fail, silently aborting whatever
depended on it, often with zero visible symptom beyond "the page just
stops progressing."
That's a bug in the target site, not in this proxy — but waiting on the
site's own team to ship a fix (guard the reference, or defer until the
real script has actually loaded) shouldn't have to block testing
everything downstream of it. injectScript (see InterceptRule in Part
- is the escape valve: point it at whatever early page the race happens
on, and stub out just enough of the missing global to stop the crash —
no-op
initialize/prompt-style methods are usually enough, since the goal is "don't throw," not "faithfully reimplement the third party." Treat it as a testing workaround, not a fix — the underlying page still has a live bug; report it upstream, and remove the rule once the real fix ships.
injectScript now works on both platforms, but via different mechanisms:
-
iOS:
_writeRewritteninReverseProxyServerprepends the script as an inline<script>tag to the HTML response body before the WebView ever sees it — gated on whether the body actually starts with<(see the gotcha below) to avoid mangling JSON responses mislabelled astext/html. -
Android: the CONNECT-tunnel proxy never touches response bodies, so
BrowserScreenhandles it in two complementary ways:- The script is registered as an
AT_DOCUMENT_STARTUserScriptat WebView creation time — it runs before any of the page's own scripts, matching the timing of the iOS approach. Because Android'sUserScripthas no per-URL filter, it fires on every page; scripts written with an idempotent guard (x = x || …) are safe. - After each
onLoadStop/onUpdateVisitedHistory,_runInjectScriptsevaluates the script again viaevaluateJavascript, this time respecting thetriggerregex — so URL-specific behaviour is enforced even though theUserScriptitself ran everywhere.
- The script is registered as an
When a page silently stops progressing with no error visible anywhere, the fastest way to find out why is to make the page tell you: prepend a tiny script to its response —
window.onerror = function(msg, url, line, col, err) {
console.log('UNCAUGHT: ' + msg + ' @ ' + url + ':' + line + ':' + col);
};
window.addEventListener('unhandledrejection', function(ev) {
console.log('UNHANDLED REJECTION: ' + ev.reason);
});— and route it through console.log, which browser_screen.dart already
captures via onConsoleMessage on both platforms. This surfaces exactly
the class of error injectScript exists to paper over, with the actual
message, file, and line — turning "it's stuck, no idea why" into a
concrete stack trace in one pass.
Gotcha, if you inject anything like this yourself while debugging: decide whether a response is "the kind of thing safe to prepend a
<script>tag to" by looking at the body, notContent-Type— a real backend can (and, on at least one profile tested against this project, does) mislabel a plain JSON API response astext/html. Gate on whether the body actually starts with<after trimming, the same way_writeRewritten'sinjectScripthandling does. Trusting the header alone means prepending a script tag onto a JSON payload and breaking the page's ownJSON.parse()call on it — which produces aSyntaxErrorthat looks exactly like a real bug but is actually self-inflicted.
Gotcha: a static start URL can go stale in your own test loop.
startPath(and the top-level page it points at) is one exact URL, requested identically every time the app launches — unlike a script asset loaded with a cache-busting query param, nothing forces a fresh fetch of it. Across repeatedflutter runreinstalls during iterative debugging, the on-device HTTP cache can keep serving an old cached copy of that page indefinitely, so a real change to your rules or debug instrumentation can look like it has no effect — you're not actually testing what you think you're testing. If a fix "doesn't work" and you can't explain why, append a throwaway query param tostartPath(?_cachebust=1, bumped on every run) before assuming the fix itself is wrong.
Configured on Profile (Part 1) for the one case dynamic subdomain
discovery can't cover: a genuinely separate domain — unrelated to
mitmDomain as a string, so shouldMitm can't recognize it — that the
flow legitimately crosses to and back, such as a third-party service
the site integrates with. start() binds this eagerly, up front,
exactly like backendHost, so its dedicated local port exists from
the moment the server starts rather than waiting for a response to
reference it.
BrowserScreen's initState calls _startIosReverseProxy() (the
Platform.isIOS branch): starts ReverseProxyServer(profile), gets back
the port it bound, and — unlike Android — that's the entire setup.
There's no separate "tell the WebView to use this proxy" call, because the
WebView isn't proxied at all in the Android sense; it's just told to
navigate to https://127.0.0.1:$port${profile.startPath} directly as its
initialUrlRequest.
onReceivedServerTrustAuthRequest's iOS branch PROCEEDs unconditionally
for host == '127.0.0.1' (this is our own local server presenting a
cert for a domain it deliberately doesn't match its own address — that's
the entire mechanism, and the trust decision is scoped correctly by the
fact that it's checking the connection's actual host, 127.0.0.1, not the
domain the cert claims to be for) and returns null (defer to normal
certificate validation) for every other host — a genuinely external,
unrewritten third party gets ordinary certificate checking, same as a
plain browser would give it.
- iOS 18.0+ (the project's deployment target; unrelated to this specific mechanism, carried over from the abandoned Network Extension attempt's Swift API requirements — no reason to lower it back).
- Your own Apple Developer Program team, selected in Xcode under Signing & Capabilities, to run this on a physical device. This project is not set up for App Store/TestFlight distribution — personal-device installs only.
- No entitlements beyond the default —
ios/Runner/Runner.entitlementsis empty; this mechanism needs no special capabilities at all.
flutter pub get
cd ios && pod install && cd ..
scripts/buildCerts.sh --all # same certs/profiles.yaml as Android
flutter build ios --no-codesign # or open ios/Runner.xcworkspace in Xcode
# to build+run on your own signed deviceRunning on your device from the command line
(flutter run -d <device>) also works once your team is selected in
Xcode at least once for this project (Xcode persists that choice into the
project's signing settings).
Add a new kind of rule — mirror whatever you add to proxy_server.dart
(Part 2) in reverse_proxy_server.dart's _handleRequest, after the
matchRule call. The two files' dispatch logic is intentionally kept in
sync by convention, not by any shared abstraction, so this is a manual
step every time.
Add a new "genuinely external" domain type — if a real flow needs
more than one separate external domain (today's externalHost config
only supports one), the natural extension is turning externalHost into
a List<String> (or a richer config shape) and adjusting start() to
bind one dedicated port per entry, plus updating _belongsToThisProfile's
host == profile.externalHost check to a set-membership check instead.
Debugging a connection — every stage logs with the [reverse_proxy]
tag via plain print(); flutter run and watch the console.
rule match for "$path": NONE|MATCHED and Upstream responded $code for $path are the two most useful lines for "did this request even reach the
real backend, and with what path" — remembering the doubled-slash gotcha
above, the path in that log line is what actually got sent, which is the
first thing to check against what you expected to be sent whenever a
response looks unrelated to the request that produced it.
Add a new target site — add an entry to assets/profiles.yaml, run
scripts/buildCerts.sh --all (or -u <its mitmDomain>). No Dart changes
needed unless the site needs a rule shape the engine doesn't support yet,
or (iOS only) crosses to more than one external domain.
Add a second simultaneous profile — see "Extending Android's proxy"
in Part 2; the same constraint (BrowserScreen currently assumes it's the
only screen with a live proxy) applies to ReverseProxyServer too, for
the same reason.
