Skip to content

Repository files navigation

Netpause logo

netpause

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 one-paragraph version

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 a CONNECT tunnel. 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.dart takes 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.


Part 1 — Cross-platform core

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.

lib/main.dart

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.

lib/profile.dart

The configuration model, and the only file that knows about assets/profiles.yaml.

  • InterceptRule — one rewrite rule. Matched by trigger (a RegExp against 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. pathReplace is itself a RegExp (see applyPathReplace in rule_engine.dart), so replaceFirst only 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) + optional injectTimestampAs (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_START UserScript in the WebView, so it runs before any page script on every load. _runInjectScripts in browser_screen.dart also re-evaluates it via evaluateJavascript after each onLoadStop, respecting the trigger regex (since UserScript has 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 "Why injectScript exists" in Part 3 before reaching for it.
  • 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 to defaultProxyPort if omitted), and rules.
    • externalHost/externalScheme/externalPort — all optional (externalHost defaults to null, meaning "not configured"; externalScheme defaults to "https"; externalPort defaults to 443). These exist only for ReverseProxyServer (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 than mitmDomain. 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() — reads assets/profiles.yaml via rootBundle and package:yaml, returns List<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.

lib/profile_select_screen.dart

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.

lib/rule_engine.dart

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 first InterceptRule whose trigger matches, or null.
  • extractValues(body, extractors) → runs each named regex against body; returns null (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}} in template with values[key].
  • formatTimestamp(dt) / pad2(n) → a yyyyMMddHHmmss formatter, available to bodyTemplates via injectTimestampAs.

lib/http_message.dart

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

lib/request_parser.dart

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 a Stream<List<int>> and exposes readLine(), readExactly(n), and readChunk() on top of that buffer. readChunk() exists specifically so proxy_server.dart can 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 _readChunkedBody when Transfer-Encoding: chunked is present) build request pieces on top of ByteStreamReader.
  • extractPath(target) — normalizes a request target down to just path?query, stripping scheme/host if the target was an absolute URL (as it is for plain-HTTP proxy requests).

lib/tls_identity.dart

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.

lib/upstream_client.dart

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.

Configuring profiles

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.

Generating certificates

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

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

Testing

flutter analyze
flutter test
  • test/rule_engine_test.dartrule_engine.dart's pure functions, Profile.fromYaml/InterceptRule.fromYaml parsing (both rule shapes, and the defaultProxyPort fallback), and that loadProfiles() actually parses the shipped assets/profiles.yaml. Cross-platform.
  • test/response_rewriter_test.dart — the pure rewrite helpers used by ReverseProxyServer (Part 3). iOS-only, but tested with no WebView, no networking, no dart:io at all.
  • test/reverse_proxy_server_test.dart — end-to-end ReverseProxyServer tests against real, hermetic local sockets (tester.runAsync + HttpOverrides.global = null, since testWidgets otherwise runs in a FakeAsync zone that blocks real socket I/O and installs an HttpOverrides that short-circuits HttpClient). iOS-only. This is also where every bug described in Part 3 has a regression test.

Part 2 — Android: transparent CONNECT-tunnel proxy

How it works

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.

Architecture

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
Loading

lib/proxy_server.dart

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:

  1. Read the first request line (via request_parser.dart, working directly on the raw socket — this file predates dart:io's HttpServer being used anywhere in this app). If it's CONNECT host:port:
    • !profile.shouldMitm(host) → respond 200 Connection Established, then _rawTunnel bytes verbatim in both directions until either side closes. The proxy never looks inside this traffic.
    • profile.shouldMitm(host) → respond 200 Connection Established, then SecureSocket.secureServer the connection using the cert from TlsIdentity, and start reading new requests off that now-decrypted socket. _serve forwards to the same host:port the client CONNECT'd to — not profile.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 to profile.backendHost/backendPort.
  2. _handleRequest runs the rule engine against the request path:
    • No rule matches → forward unchanged, emit ProxyEventKind.passthrough.
    • pathReplace rule → 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 to destinationPath, emit .transformed. If that forward throws, also fall back to _forwardOriginal (.fallback) rather than dropping the connection.
  3. _writeResponse writes the upstream's status line, headers (dropping transfer-encoding/content-length/content-encoding/connection, which get recomputed), and body back onto the client socket, then connection: closes — every request on the MITM'd socket effectively gets its own upstream UpstreamClient() 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.

browser_screen.dart on Android

BrowserScreen's initState branches on Platform.isIOS — everything in this subsection is the else branch. _initProxy():

  1. Starts a ProxyServer(profile) and gets back the port it bound.
  2. 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.
  3. Subscribes to _proxyServer.events to show SnackBars.
  4. Once the port is known, builds the InAppWebView with initialUrlRequest: 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.

TLS: MITM-ing your own WebView traffic, legitimately

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:

  1. Each certificate is self-signed, generated locally, and loaded only into this app's own dart:io SecurityContext — it's never installed as a device-wide trusted CA, and it never leaves the device.
  2. Trust for it is scoped two ways: Profile.shouldMitm(host) at the app level (browser_screen.dart's onReceivedServerTrustAuthRequest), and a matching per-domain <domain-config> in network_security_config.xml at the Android/Chromium level (see below) — both restricted to exactly that profile's mitmDomain (+ subdomains). Every other host the device talks to is unaffected.

network_security_config.xml

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.

Building and running on Android

flutter pub get
scripts/buildCerts.sh --all
flutter build apk --debug   # or: flutter run

Extending Android's proxy

Add a new kind of ruleInterceptRule currently supports three proxy-layer rule shapes (path-swap, body-merge, injectScript) plus DOM automation (htmlAutomation). To add a fourth proxy-layer shape:

  1. Add the new fields to InterceptRule and its fromYaml factory in lib/profile.dart (Part 1).
  2. Add a branch for it in proxy_server.dart's _handleRequest, after the matchRule call — follow the existing if (rule.pathReplace != null) { ... } branch as a template: forward via UpstreamClient.send, emit a ProxyEvent, call _writeResponse. If you want the same rule shape to work on iOS too, make the equivalent edit in reverse_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.
  3. Add coverage in test/rule_engine_test.dart if the new shape adds logic to rule_engine.dart itself (rather than just to proxy_server.dart's orchestration).

Note on injectScript: this rule shape is already fully supported on Android — BrowserScreen registers each injectScript rule as an AT_DOCUMENT_START UserScript and re-evaluates it on every onLoadStop. No changes to proxy_server.dart are needed; adding a new injectScript rule to profiles.yaml is 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.


Part 3 — iOS: local reverse proxy

Why this is a different mechanism, not a platform tweak

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.

lib/reverse_proxy_server.dart

One dedicated local port per real host

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.backendHost and (if configured) profile.externalHost are bound up front in start().
  • 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.

_handleRequest / _forward

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.path can silently drop a path segment. dart:io's HttpServer parses the raw request line into a Uri for 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 hit action="https://realsite.example//cc/submit.php" in the wild — cosmetic to a real browser, which treats the doubled / as harmless). Dart's Uri parser 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.path alone 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-looking 200 OK with completely unrelated content and no error anywhere to point at the real cause.

The fix, in _handleRequest: check request.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 real HttpServer, not just Uri.parse in isolation, and covered by a regression test in test/reverse_proxy_server_test.dart. proxy_server.dart never hits this at all — it parses request lines itself, byte-for-byte, via request_parser.dart, and never routes the raw target through Uri in a way that re-interprets it.

lib/response_rewriter.dart

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 every https://host, http://host, and protocol-relative //host reference in text (with or without an explicit port), returning each as a HostReference(host, port). Doesn't modify anything — ReverseProxyServer._rewriteText calls 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 because String.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 to 127.0.0.1:<that host's local port>, preserving whichever form (https:///http:///protocol-relative) the original used — both schemes upgrade to https, 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 the Domain= attribute from a Set-Cookie value. A cookie whose Domain doesn't match the serving host is silently rejected by the browser per RFC 6265 anyway, and once responses are served from 127.0.0.1 instead of the real domain, every configured Domain is 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) — removes Content-Security-Policy/Content-Security-Policy-Report-Only/ Access-Control-Allow-Origin. A CSP written for the real domain would disallow scripts/connections now pointed at 127.0.0.1; a narrow CORS allow-list would reject requests from the new origin.

Two rewrite directions, not one

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.

Why the rewritable-content-type allowlist covers more than HTML

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

Why UpstreamClient is one shared instance, not one per request

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.

Why injectScript exists (and how it works on each platform)

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

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

How injectScript is delivered per platform

injectScript now works on both platforms, but via different mechanisms:

  • iOS: _writeRewritten in ReverseProxyServer prepends 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 as text/html.

  • Android: the CONNECT-tunnel proxy never touches response bodies, so BrowserScreen handles it in two complementary ways:

    1. The script is registered as an AT_DOCUMENT_START UserScript at WebView creation time — it runs before any of the page's own scripts, matching the timing of the iOS approach. Because Android's UserScript has no per-URL filter, it fires on every page; scripts written with an idempotent guard (x = x || …) are safe.
    2. After each onLoadStop/onUpdateVisitedHistory, _runInjectScripts evaluates the script again via evaluateJavascript, this time respecting the trigger regex — so URL-specific behaviour is enforced even though the UserScript itself ran everywhere.

Finding this kind of bug in the first place

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, not Content-Type — a real backend can (and, on at least one profile tested against this project, does) mislabel a plain JSON API response as text/html. Gate on whether the body actually starts with < after trimming, the same way _writeRewritten's injectScript handling does. Trusting the header alone means prepending a script tag onto a JSON payload and breaking the page's own JSON.parse() call on it — which produces a SyntaxError that 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 repeated flutter run reinstalls 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 to startPath (?_cachebust=1, bumped on every run) before assuming the fix itself is wrong.

externalHost/externalScheme/externalPort

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.

browser_screen.dart on iOS

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.

Requirements

  • 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.entitlements is empty; this mechanism needs no special capabilities at all.

Building for iOS

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 device

Running 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).

Extending iOS's proxy

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.


Extending the shared parts

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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages