Skip to content

Latest commit

 

History

History
1000 lines (809 loc) · 47.5 KB

File metadata and controls

1000 lines (809 loc) · 47.5 KB

MITM Rewrite System — Developer Guide

Anywhere can intercept the HTTP traffic of selected domains — terminating TLS for HTTPS, or reading plain HTTP directly — then inspect, rewrite, and forward it to the upstream: a man-in-the-middle (MITM) on traffic you control. This guide covers everything needed to author rules and scripts. It is reference-level and assumes you are comfortable with HTTP, regular expressions, and JavaScript. It does not cover the settings UI.

Prerequisite (HTTPS only). Intercepting HTTPS works only for clients that trust Anywhere's generated root CA. Install and trust it first. Apps that pin certificates cannot be intercepted (their TLS handshake to the minted leaf certificate will fail) — this is expected, not a bug. Plain HTTP carries no certificate, so it needs no CA trust.

Contents


How it works

An HTTPS connection is intercepted when its TLS ClientHello SNI host matches a configured rule set. Anywhere then:

  1. Mints a leaf certificate for the requested host (cached) and completes the inner TLS handshake with the client, negotiating ALPN from the client's own offer.
  2. Reads the first request and applies the matching rules. A rewrite rule can answer on the inner leg (302 / reject) — no upstream at all — or change the destination host.
  3. Defers opening the outer leg until the destination is known, then dials it (the rewritten host when set, otherwise the original) and runs its own TLS handshake there. An HTTP/1.1 client is shuttled to an HTTP/1.1 upstream; an HTTP/2 client is bridged to whichever protocol the upstream speaks — HTTP/2 directly, or HTTP/1.1 with on-the-fly translation.
  4. Decrypts each direction, runs the matching rules, and re-encrypts to the opposite leg.

Plain HTTP (no TLS) is intercepted the same way, gated on the request host instead of the SNI. When a connection isn't TLS, Anywhere reads the first request's authority — the DNS-resolved name for a fake-IP route, otherwise the Host header — and intercepts it when that matches a rule set. No certificate is minted and neither leg runs a handshake (the upstream is dialed in cleartext too); otherwise the rewrite pipeline is identical, and ctx.url simply carries an http:// scheme. Cleartext is always treated as HTTP/1.1 — h2c is not intercepted.

Traffic is processed in two phases:

  • Request (httpRequest) — the client→server direction, before the request leaves for upstream.
  • Response (httpResponse) — the server→client direction, before the response reaches the client.

Both HTTP/1.1 and HTTP/2 are supported. For HTTP/2 the rewriter operates on decoded header lists and whole-body buffers; for HTTP/1.1 it drives a byte-level framing state machine. Either way the rule model below is identical.

A rule set's hostname suffixes gate which hosts are intercepted; each rule's url-pattern regex gates which requests within those hosts it acts on. A request that matches no rule is forwarded unchanged (its body is streamed through without buffering), so the marginal cost of an intercepted-but-unrewritten request is small — but interception itself (the extra TLS handshakes) is not free. Scope hostname as tightly as you can.

Performance note. All script execution across all connections runs on a single serial queue, and one JavaScript runtime is shared by every connection to the same rule set. A script that loops forever, recurses without bound, or triggers catastrophic regex backtracking will stall every other script sharing that runtime, not just its own connection — CPU-bound execution can't be preempted (the idle-async watchdog under Limits doesn't cover a running loop; a separate hard-cap watchdog crashes and relaunches the extension after ~30 s, so a runaway self-recovers rather than wedging for the process's life). Keep scripts bounded. (Awaiting an Anywhere.http fetch is the exception: while it is in flight the connection is parked but the shared runtime is free, so other connections' scripts keep running — only CPU-bound work monopolizes the runtime.)


Rule sets

A rule set is the unit of configuration:

Field Meaning
name Display name. Required, non-empty.
domainSuffixes Hosts to intercept, matched by suffix. example.com covers www.example.com. No wildcards.
rules Ordered list of rewrite rules. Redirect / reject / host-rewrite are per-rule via the rewrite operation.

Suffix matching is most-specific-win: if both example.com and api.example.com are configured, a request to api.example.com uses only the latter set. Each connection resolves to exactly one rule set.

Rule sets are authored as text (pasted or downloaded from a URL) and stored internally as JSON. The text format below is the authoring interface.


The import format

A rule set is a flat sequence of header lines and rule lines, in any order. Blank lines are ignored; lines beginning with # or // are comments. Parsing never hard-fails — a line that is neither a recognized header nor a valid rule is dropped silently, so a partially valid file still imports what it can.

# A complete example
name        = My Rule Set
hostname    = example.com, api.example.org

# request: transparently rewrite the whole URL to a new host (dials it + rewrites Host)
0, 0, ^https://example\.com/old, 0, https://upstream.example.com/new
# request: add a header on /api/ paths
0, 1, ^/api/, X-Powered-By, Anywhere

Header lines

Shape: <key> = <value>. Keys are case-insensitive; the value is trimmed and otherwise kept verbatim.

Key Meaning
name Display name (required).
hostname Comma-separated domain suffixes.

Unrecognized keys are ignored. Redirect / reject / host-rewrite are configured per-rule via the rewrite operation, not as set-level headers.

Rule lines

Shape:

<phase>, <operation>, <field1> [, <field2> [, <field3>]]
  • Phase: 0 = request, 1 = response.
  • Operation and its trailing fields:
ID Operation Phase Fields
0 rewrite request only url-pattern, sub-mode, <sub-mode args>
1 header-add both url-pattern, name, value
2 header-delete both url-pattern, name
3 header-replace both url-pattern, name, value
4 body-replace both url-pattern, search, replacement
5 body-json both url-pattern, action, <action args>
100 script both url-pattern, base64
101 stream-script both url-pattern, base64

Scripting operations use a separate 100+ id range, set apart from the native edits.

rewrite (op 0) is always request-phase regardless of the phase column. Its second field is a numeric sub-mode; the remaining fields depend on it — see rewrite (0). A rule whose field count does not match, or whose url-pattern is empty or fails to compile as a regex, is dropped. For body-replace the search field must also be a valid regex; for body-json the trailing fields depend on action — see body-json (5).

Fields and quoting

Fields are separated by ,. Whitespace around an unquoted field is trimmed. A field beginning with " is read until the matching ", and "" inside a quoted field is a literal ". Quote any field that contains a comma or significant leading/trailing whitespace:

0, 1, ^/, X-Note, "value, with a comma"

The url-pattern

Every rule leads with a url-pattern: an NSRegularExpression (default Unicode semantics) tested against the whole request URL — e.g. https://api.example.com/login?token=abc. It is purely a gate (the replace operations carry their own search regex); it does not see the method or HTTP version. Use .* to match every request, or anchor on the scheme/host (^https://api\.example\.com/) to scope by origin — but note an intercepted plain-HTTP request's URL has an http:// scheme, so anchor on ^https?:// (or just the host) when a rule set also covers cleartext. The rule fires only when the URL pattern matches. The host is matched case-insensitively — it is lowercased before the test, so write hosts in lowercase — while the path and query keep their case.

For response-phase rules, the gate is tested against the originating request's URL (response heads carry no path), so a request and its response can be matched by the same URL pattern.


Rule operations

rewrite (0) — request only

The unified rewrite operation. Its second field is a numeric sub-mode; the remaining field(s) depend on it. When the url-pattern gate matches, the first matching rewrite rule wins.

Sub-mode Name Args Effect
0 transparent <full-url> Replace the whole request URL with <full-url> (which may carry $1-style capture references — see below). The request-target becomes the replacement's path+query; the outer leg is dialed to the replacement host and Host / :authority is rewritten to match it (a no-op in effect when the host is unchanged). The client still sees the original host on the leaf certificate.
1 302 redirect <full-url> Synthesize a 302 Found whose Location is <full-url>. No upstream dial.
2 reject 200 text [<content>] Synthesize a 200 OK with a text/plain; charset=utf-8 body. Empty <content> → a short default line. No upstream dial.
3 reject 200 gif (none) Synthesize a 200 OK carrying the canned 1×1 image/gif. No upstream dial.
4 reject 200 data [<base64>] Synthesize a 200 OK with an application/octet-stream body decoded from <base64>. Empty → a default payload. No upstream dial.

For sub-modes 0 and 1 the URL — after capture expansion — must be a full absolute URL with a host (https://host[:port]/path?query); a URL with no path uses /. The replacement supports capture references against the url-pattern match: $0 is the whole match, $1$9 (and ${10}, ${11}, … for higher indices) are its capture groups, and $$ is a literal $. A replacement containing no $ is used verbatim, so existing rules are unaffected. References resolve per request, so one url-pattern with capture groups can rewrite many URLs to matching targets; a group that didn't participate expands to the empty string, and if the expanded URL isn't a valid absolute URL the rule is skipped for that request.

Because a transparent rewrite can change the dial target, the upstream dial is deferred: the inner TLS handshake completes first (negotiating ALPN from the client), the first request is read and rewritten, and only then is the upstream dialed — to the rewritten host when one is set, otherwise the original. A 302 / reject sub-mode answers on the inner leg and never dials. (Consequence: the inner ALPN is client-driven, and the upstream protocol is negotiated separately on the first dial. If the client negotiates h2 but the upstream speaks only HTTP/1.1, Anywhere bridges the two — translating the client's HTTP/2 to the HTTP/1.1 upstream (one short-lived upstream connection per request stream) and the responses back — so no downgrade or client retry is needed. For an HTTP/1.1 connection the single upstream leg is fixed by the first request, so a later request whose transparent rewrite resolves a different host/port can't be reached on the already-dialed leg. If that leg is idle (no response in flight), Anywhere reconnects it to the new target transparently, so the client sees no drop; if a response is still in flight, the connection is torn down instead and the client retries it on a fresh connection. A bridged HTTP/2 client commits its upstream on the first request too: an HTTP/1.1 upstream is dialed per stream and so generally follows each stream's own resolved host (a request held back for a body rewrite resolves its target as it is finally emitted, so a concurrent stream's rewrite to a different host can win), while an HTTP/2 upstream multiplexes every stream over the one committed connection, so a later stream whose rewrite resolves a different host is sent to that committed upstream rather than re-routed. Either way, avoid splitting one origin's traffic across several transparent target hosts.)

Examples — transparently send one host's traffic to another, move a host's traffic while preserving the path with a $1 capture, redirect with a 302, and block an ad path with a tiny GIF:

0, 0, ^https://a\.example\.com/, 0, https://b.example.com/
0, 0, ^https://old\.example\.com/(.*), 0, https://new.example.com/$1
0, 0, ^https://old\.example\.com/page, 1, https://new.example.com/page
0, 0, .*/ads/, 3

header-add (1)

Appends a header (does not replace an existing one of the same name):

0, 1, .*, X-Trace-Id, anywhere

header-delete (2)

Removes every header with the given name (case-insensitive):

1, 2, .*, Set-Cookie

header-replace (3)

Overwrites the value of every header with the given name (case-insensitive). A header that is not present is left alone — it does not add it:

1, 3, .*, Cache-Control, no-store

body-replace (4)

Regex find-and-replace over the text body in native code, without writing any JavaScript. Its fields are url-pattern (the URL gate), a search regex, and a replacement:

1, 4, .*, http://, https://
1, 4, .*, (?i)debug=true, debug=false
1, 4, .*, (\d{4})-(\d{2})-(\d{2}), $3/$2/$1

search is a Swift Regex (default Unicode semantics) matched against the whole decompressed body, and every match is swapped for replacement. The replacement supports capture references to the search match: $0 is the whole match, $1$9 (and ${10}, ${11}, … for higher indices) are its capture groups, and $$ is a literal $. A replacement with no $ is inserted verbatim, and an empty replacement deletes every match. A rule whose search is empty or won't compile as a regex is dropped. Per the Fields and quoting rules, quote either field when it contains a comma or begins with ", doubling any inner " — so searching for the literal text "price": is written as the field """price"":".

Like script, body-replace is a buffered body transform: the rewriter accumulates the body (auto-decoding gzip / deflate / br, up to the same 4 MiB cap), edits it, and re-emits with a corrected Content-Length. The body is decoded as UTF-8, falling back to ISO-8859-1 (latin-1) for single-byte charsets (Windows-125x / ISO-8859-x); multi-byte charsets (UTF-16, GBK, …) are not decoded and pass through unchanged. The contract is total — a body decodable as neither, a search that matches nothing, or a replacement that can't be represented in the body's charset leaves the body unchanged. Unlike script, every matching body-replace rule fires, in rule order, so replacements compose.

When several body transforms match the same message they run in a fixed order: body-json edits first, then body-replace, then a script (so the script sees the fully-edited body).

body-json (5)

Declarative JSON body editing in native code — the same edits as the Anywhere.json script API, without writing any JavaScript. One rule carries one edit; its fields are url-pattern, an action token, and the action's own fields:

action Trailing fields Effect
add path, value Upsert at path (create or overwrite; append at array end).
replace path, value Overwrite at path only if the member/index already exists.
delete path Remove the member/element at path.
replace-recursive key, value Overwrite every property named key at any depth.
delete-recursive key Remove every property named key at any depth.
remove-where-key-exists path, key At the array at path, drop objects containing key.
remove-where-field-in path, field, values At the array at path, drop objects whose fieldvalues.

path is a JSONPath like $.data.items[0].id (leading $ optional; dotted keys and [index] / ["key"] brackets). value / values are written as JSON literals (true, 42, "text", {"a":1}, ["x","y"]); a string that isn't valid JSON is taken literally, so value = Anywhere means the string "Anywhere". Action tokens are case-insensitive and also accept the camelCase spelling (replaceRecursive). A rule whose path can't be parsed is dropped.

Like script, body-json is a buffered body transform: the rewriter accumulates the body (auto-decoding gzip / deflate / br, up to the same 4 MiB cap), edits it, and re-emits with a corrected Content-Length. The contract is total — a body that isn't JSON, a path that doesn't resolve, or a non-serializable result leaves the body unchanged (byte-for-byte; a rule that matches but changes nothing never reshapes the body). A successful edit, though, re-serializes the whole document, so a JSON integer beyond 2^53 anywhere in it can lose precision, and object members are re-serialized in an unspecified order (source key order is not preserved). Unlike script, every matching body-json rule fires, in rule order, so edits compose; when a script rule also matches the same message, the JSON edits run first and the script sees the already-edited body (after any body-replace edits).

Examples — flip a flag, drop a field, and filter an array on the response:

1, 5, ^/api/user, add, $.user.vip, true
1, 5, ^/api/user, delete, $.user.password
1, 5, ^/api/feed, remove-where-field-in, $.items, status, expired

A value / values that contains a comma — a multi-element array or a multi-key object — has to be one quoted CSV field with each inner " doubled, since the field separator is also ,. So matching several values is either one quoted array or one rule per value (they compose):

1, 5, ^/api/feed, remove-where-field-in, $.items, status, "[""expired"",""deleted""]"
1, 5, ^/api/profile, add, $.meta, "{""beta"":true,""tier"":2}"

Set a string value (CSV-quote it when it contains a comma) and redact a token wherever it appears:

1, 5, ^/api/profile, replace, $.tier, "gold, platinum"
1, 5, .*, replace-recursive, access_token, "***"

script (100) / stream-script (101)

JavaScript transforms. The field is base64-encoded UTF-8 source defining function process(ctx). See the next sections.


Scripting: script

Use script whenever the rewrite needs the whole message at once: rewriting a body as a unit (JSON, protobuf, JWT, a regex over the full text) or short-circuiting a request with Anywhere.respond(...). The head is read-only — URL and header edits have dedicated rules (rewrite, header-add / header-delete / header-replace), and ctx.method / ctx.status aren't script-writable either — so a script rule's job is the body (plus the Anywhere.done / exit / respond control directives).

The rewriter buffers the body — auto-decoding gzip / deflate / br — runs process(ctx) once, and re-emits with a corrected Content-Length. Because nothing reaches the client until the body is complete, a script rule de-streams the response; it is right for ordinary request/response APIs and wrong for live streams (pointing one at a streaming media type still runs but logs a warning recommending stream-script).

process may be declared async and await an Anywhere.http request mid-rewrite; the rewriter waits for the returned Promise to settle before reading ctx.body back, so the connection parks while the fetch is in flight (the shared script runtime stays free for other connections). This is the one case where a script does more than transform the bytes already in hand. stream-script has no such facility — Anywhere.http is unavailable there.

The body is held up to a 4 MiB cap; larger Content-Length bodies fall back to passthrough, and chunked bodies are truncated at the cap.

Authoring a script rule:

1, 100, ^/api/user, <base64 of the JS source>

To produce the base64 from a source file:

printf '%s' "$(cat process.js)" | base64

A rule whose base64 does not decode to syntactically valid UTF-8 JavaScript is dropped at import; whether process is defined and callable is checked at runtime (a missing/non-function process logs a warning and passes the message through unchanged).


Scripting: stream-script

Use stream-script when the response must keep flowing and must not stall: Server-Sent Events (text/event-stream), chunked event / NDJSON feeds, gRPC or HTTP/2 DATA streams, or any long-lived or very large body. process(ctx) runs once per frame (HTTP/2 DATA frame or HTTP/1 chunked chunk) and the body is never buffered, so bytes reach the client as they arrive.

The trade-off is a narrower contract:

  • The head is immutablectx.url / ctx.method / ctx.status / ctx.headers are read-only (the head is already on the wire).
  • No HTTP-level decompression. ctx.body is the raw frame payload.
  • No HTTP/1 Content-Length bodies — the byte count is already committed and can't change mid-stream, so length-prefixed HTTP/1 bodies are skipped (chunked is required). HTTP/2 has no such restriction.
  • Not applied on the HTTP/2→HTTP/1.1 bridge. When an HTTP/2 client is bridged to an HTTP/1.1 upstream, a matching stream-script is skipped — the body is forwarded unscripted (with a logged warning) — in both request and response directions, since the bridge translates between framings rather than running a per-frame script across them. Buffered script / body-replace / body-json rules still apply on the bridge.

Per-frame context adds:

  • ctx.frame{ index, end }: the 0-based frame index and an end flag set on the final frame.
  • ctx.state — a JS object persisted across frames of the same stream. Mutate it to accumulate state; it starts as {}.

Authoring is identical to script but with op 101:

1, 101, ^/events, <base64>

The ctx object

process(ctx) receives a context object. Scripts read its fields freely, but the only one read back is ctx.body — replace it or mutate it in place.

Field Type Phase Mutable Notes
ctx.phase "request" / "response" both no Reassigning is a no-op.
ctx.method string or null both no Read-only. On response, the originating request's method.
ctx.url string or null both no Read-only — use a rewrite rule. Absolute URL; on response, the originating request's URL.
ctx.status number or null response no Read-only. null on request.
ctx.headers array of [name, value] both no Read-only — use header-add / header-delete / header-replace rules. Pairs; preserves duplicates and order.
ctx.body Uint8Array both yes Backed by native memory; element-wise writes propagate.

Only ctx.body is mutable — in both script and stream-script (the latter also reads back ctx.state). Every head field (method, url, status, headers, phase) is read-only: assigning it is ignored on readback. URL and header edits have dedicated rule operations — rewrite and header-add / header-delete / header-replace — so scripts don't duplicate them; method and status have no script-side write at all. Keeping the head read-only also lets the HTTP/2 path open a request stream in stream-ID order without waiting on the script.

Readback (the wire stays well-formed by construction):

  • Only ctx.body is adopted; every head-field assignment is ignored, so a script can't inject a malformed request line, status, or header.
  • An uncaught exception discards all mutations and emits the original message unchanged (use try/catch, or signal a directive before throwing, to keep partial work).

The Anywhere API

A global Anywhere object exposes helpers. Byte convention: functions that take "bytes" accept a Uint8Array, an ArrayBuffer, or a string (UTF-8 encoded); functions that return bytes return a Uint8Array.

Anywhere.codec

Encoder/decoder pairs.

Member encode decode
Anywhere.codec.utf8 encode(string) → Uint8Array decode(bytes) → string
Anywhere.codec.base64 encode(bytes) → string decode(string) → Uint8Array
Anywhere.codec.base64url encode(bytes) → string decode(string) → Uint8Array
Anywhere.codec.hex encode(bytes) → string decode(string) → Uint8Array
Anywhere.codec.gzip encode(bytes) → Uint8Array decode(bytes) → Uint8Array
Anywhere.codec.deflate encode(bytes) → Uint8Array decode(bytes) → Uint8Array
Anywhere.codec.brotli encode(bytes) → Uint8Array decode(bytes) → Uint8Array

base64url emits unpadded RFC 4648 §5; decode accepts either alphabet, padded or not, and ignores embedded whitespace. The compression codecs are for payloads the pipeline doesn't already handle (a gzipped blob nested in a JSON field, re-compressing a body for Anywhere.respond, etc.) — the outer Content-Encoding is auto-decoded for script rules already. decode throws on malformed input or output exceeding the 4 MiB cap.

Anywhere.codec.protobuf

Schema-free protobuf wire-format codec.

  • decode(bytes) → [{ field, wire, value }] — flat list preserving on-wire order (repeated fields appear as multiple entries).
  • encode(entries) → Uint8Array — takes the same shape back.
  • encodeVarint(n) → Uint8Array, decodeVarint(bytes, offset?) → { value, consumed } | null.

Value types by wire type: wire 0 (varint) is a BigInt (so 64-bit IDs round trip); wire 1 / 5 (fixed64 / fixed32) are Uint8Array of length 8 / 4 (the script picks the interpretation with a DataView); wire 2 (length-delimited) is a Uint8Array — recurse with decode for nested messages. Group wire types (3, 4) are rejected.

Anywhere.crypto

Hashes and HMAC return raw digest bytes (Uint8Array); compose with Anywhere.codec.hex.encode / base64.encode to format.

  • md5, sha1, sha256, sha384, sha512(bytes) → Uint8Array.
  • hmacSHA1, hmacSHA256, hmacSHA384, hmacSHA512(key, data) → Uint8Array.
  • randomBytes(n) → Uint8Arrayn in [0, 65536]; out-of-range / non-integer throws.
  • uuid() → string — lowercased.
  • aesGCM.encrypt(spec) → { nonce, ciphertext, tag } and aesGCM.decrypt(spec) → Uint8Array. The spec object:
    • key: Uint8Array of 16 / 24 / 32 bytes (AES-128/192/256).
    • nonce: 12-byte Uint8Array — exactly 12 bytes; any other length throws. On encrypt, omit it to have a fresh random nonce generated and returned in the result.
    • plaintext / ciphertext: bytes.
    • tag: 16-byte Uint8Array (decrypt only).
    • aad: optional additional authenticated data.
    • decrypt throws a catchable error on auth failure (wrong key, tampered data, mismatched AAD).

Anywhere.jwt

JWT compact serialization (RFC 7519 / 7515). Pure codec — no signature verification or alg enforcement; do that yourself with the crypto helpers.

  • decode(token) → { header, payload, signature, signingInput }. header is parsed JSON; payload is parsed JSON or a Uint8Array for binary payloads; signature is bytes; signingInput is the header.payload octet string to recompute the signature over.
  • encode({ header, payload, signature? }) → string. Object header/payload are JSON.stringify'd; bytes/string are used verbatim. signature is the raw signature bytes.

Anywhere.json

Byte-oriented JSON editing: every method is bytes-in / bytes-out (first arg is the body; returns a fresh Uint8Array of re-serialized compact JSON). The contract is total — a body that isn't JSON, a path that doesn't resolve, a type mismatch, or a non-serializable value all yield the body unchanged (byte-for-byte) rather than throwing. A successful edit re-serializes the whole document, so a JSON integer beyond 2^53 anywhere in it can lose precision and its object members are re-serialized in an unspecified order (key order is not preserved).

  • add(body, path, value) — upsert at a JSONPath.
  • replace(body, path, value) — modify only if the member/index already exists.
  • replaceRecursive(body, key, value) — replace every property named key at any depth (bare key name, not a path).
  • delete(body, path) — remove the addressed member/element.
  • deleteRecursive(body, key) — remove every property named key at any depth.
  • removeWhereKeyExists(body, path, key) — at the array at path, drop objects containing key.
  • removeWhereFieldIn(body, path, field, values) — at the array at path, drop objects whose field equals one of values (array or scalar).

Paths use JSONPath like $.data.items[0].id (leading $ optional; dotted keys and [index] / ["key"] brackets). Recursive methods take a bare key name.

For these same edits without a script — declared as a rule and run in native code — use the body-json (5) operation. A script is only needed when the edit must be conditional, computed, or combined with Anywhere.respond / control directives.

Anywhere.store

Per-rule-set key/value state, scoped by rule-set id.

  • get(key[, onDisk]) → Uint8Array | undefined
  • getString(key[, onDisk]) → string | undefined
  • set(key, value[, onDisk]) — value is bytes. Throws when the write would exceed the scope's 1 MiB cap or the 16 MiB process-wide store cap (catch it and shed entries with delete); the on-disk backing also throws on a failed write.
  • delete(key[, onDisk])
  • keys([onDisk]) → [string]

Every method is shared across every connection to the same rule set — and across the rule set's script and stream-script rules — and survives a rule-set edit. State is cleared when the rule set is removed (a disabled set keeps its data).

The optional onDisk flag (default false) selects the backing:

  • onDisk: false — in-memory. Fast, but cleared when the extension restarts (the tunnel stopping, a device reboot, an NE relaunch). Use it for per-session caches.
  • onDisk: true — persisted to a file in the App Group container, so it survives extension restarts. Use it for tokens, cookies, or check-in state that must outlive a single tunnel session.

The two backings are separate keyspaces with independent caps: an in-memory count and an on-disk count are different entries, and keys() only lists the backing you ask for. Scripts must tolerate a missing key in either.

// Persist a refreshed token across tunnel restarts; fall back to a fetch.
async function process(ctx) {
  let token = Anywhere.store.getString("token", true);
  if (!token) {
    const r = await Anywhere.http.get("https://api.example.com/token");
    if (r.status === 200) {
      token = Anywhere.codec.utf8.decode(r.body).trim();
      try { Anywhere.store.set("token", token, true); }
      catch (e) { Anywhere.log.warning("store full: " + e); }
    }
  }
  if (token) ctx.headers.push(["Authorization", "Bearer " + token]);
}

Anywhere.log

info(msg), warning(msg), error(msg), debug(msg) — written through the shared logger, prefixed [MITM][JS]. debug is os.log-only.

Anywhere.http

Make an outbound HTTP(S) request from a script and await the response — to fetch a token, look up data to splice into the body, or call a sidecar API mid-rewrite. Available in script rules only (not stream-script), and the result must be awaited, so declare process as async:

async function process(ctx) {
  const r = await Anywhere.http.get("https://api.example.com/token");
  if (r.status === 200) {
    const token = Anywhere.codec.utf8.decode(r.body).trim();
    ctx.body = Anywhere.codec.utf8.encode(JSON.stringify({ token }));
  }
}
  • get(url[, options]) → Promise<Response>
  • post(url[, options]) → Promise<Response>
  • request(options) → Promise<Response> — the all-options form; url is a field of options.

Response: { status, headers, body, url }status is the numeric HTTP status; headers is [[name, value], …] like ctx.headers (URLSession combines duplicate field names, and header order is not preserved); body is a Uint8Array; url is the final URL after any followed redirects. The Promise rejects with an Error on a transport failure, a timeout, a cap breach, or a non-HTTP response — wrap the await in try/catch to handle it. An uncaught rejection reverts the message unchanged, exactly like any other uncaught throw.

options:

Field Default Meaning
method "GET" / "POST" HTTP method.
headers none [[name, value], …] or a { name: value } object. Entries with an invalid field-name, a CR/LF/NUL value, or a forbidden name (Host, Content-Length, Connection, Transfer-Encoding, and other framing / hop-by-hop headers) are dropped.
body empty Request body: Uint8Array, ArrayBuffer, or string.
timeout 10 000 ms Per-request timeout in milliseconds, clamped to 30 000.
redirect "follow" "follow" chases 3xx; "manual" returns the 3xx response as-is.
insecure global Allow Insecure true accepts self-signed server certificates.

Execution model. A script that awaits a fetch is parked — its connection waits for the response — but the shared script runtime is not blocked: other connections' scripts keep running while this one is in flight (unlike a CPU-bound loop, which still monopolizes the runtime — see the performance note). The request leaves as the extension's own traffic and is not itself intercepted by the MITM, so a script may safely call a host the rule set also intercepts without looping.

Because other invocations run during an await, another connection running the same rule set can mutate shared globalThis state between your await and its resumption — don't assume exclusive access across a suspension. Per-message state lives on ctx; cross-connection state belongs in Anywhere.store, whose sharing semantics are already explicit.

Security. Anywhere.http performs no destination filtering — a script can reach any address the device can, including localhost, *.local, and loopback / link-local (incl. the cloud-metadata address) / private / ULA ranges, on the physical interface outside the tunnel. It is both an exfiltration surface (a script can send data it has read to any host) and a pivot into on-device and on-network services. Author and import rule sets only from sources you trust.

Control directives

  • Anywhere.done() — commit the current ctx as the final result and skip any remaining rules. In stream-script, emit this frame's body and pass every subsequent frame through unchanged.
  • Anywhere.exit() — discard this rule's mutations: revert to the message as it entered (buffered), or emit the original frame and stop scripting the stream.
  • Anywhere.respond({ status, headers, body })request-phase only. Drop the request before it reaches upstream and synthesize a response straight back to the client. All fields optional: status defaults to 200 (clamped to 100–599), headers to [], body to empty. Anywhere owns framing, so Content-Length and hop-by-hop headers (transfer-encoding, connection, keep-alive, upgrade, proxy-connection, te, trailer) you set are dropped, and a Date is stamped when absent. Ignored (with a warning) on the response phase and in stream-script.

These set engine state and return; your code should return immediately after calling one.


Single-rule semantics

At most one script and one stream-script fire per message, by design (it keeps the hot path lean and avoids state collisions). When several rules of the same kind match a request's URL, the last in rule order wins — later definitions overwrite earlier ones. When both a script and a stream-script match, stream-script wins.

If you need composed behavior, consolidate the logic into a single process(ctx) rather than splitting it across rules. Static operations (rewrite, header-*) are not capped — all matching ones apply in order.


Limits and safety

Limit Value Effect on exceed
Buffered body (script) 4 MiB Content-Length → passthrough; chunked → truncated
Per-scope Anywhere.store (memory) 1 MiB set throws capacity exceeded
Total Anywhere.store (memory) 16 MiB set throws capacity exceeded
Per-scope Anywhere.store (onDisk) 1 MiB set throws capacity exceeded
Total Anywhere.store (onDisk) 16 MiB set throws capacity exceeded
Anywhere.crypto.randomBytes 64 KiB throws
Synthesized response body 4 MiB truncated
Anywhere.http timeout 10 s default / 30 s max Promise rejects
Anywhere.http per script 4 concurrent / 16 total Promise rejects
Anywhere.http concurrent requests (all scripts) 32 Promise rejects
Anywhere.http in-flight body bytes (all scripts) 16 MiB Promise rejects
Anywhere.http response body 4 MiB Promise rejects
HTTP/1 request/response head 64 KiB fails closed — connection closed (request) / 502 (response)
Typed-array memory (all scripts) 16 MiB / 32 MiB soft → GC hint; hard → empty Uint8Array returned
Idle suspended async script ~60 s no progress reverted to original, released
Runaway synchronous JS span ~30 s extension crashes & relaunches clean

Other safety properties:

  • Wire safety. Header names, header values, methods, and request targets produced by scripts are validated; CR/LF/NUL and other smuggling vectors are rejected so a script can't split the wire framing.
  • Watchdogs. Two cover a stuck script. Idle async: a suspended async script that stops making progress — a never-settling Promise or an abandoned await — is reverted to the original message and released after an idle stretch longer than the maximum per-fetch timeout (~60 s), so it can't park its connection forever. Runaway sync: a CPU-bound loop or pathological regex still can't be preempted, so it wedges its own connection and stalls the scripts queued behind it — but a synchronous span that runs past a ~30 s hard cap crashes the extension so the OS relaunches it clean, rather than letting the wedge last the process's life. Either way, keep loops and regexes bounded. (Awaiting an Anywhere.http fetch does not monopolize the runtime — see its execution-model note.)
  • Outbound requests. Anywhere.http lets a script make the extension issue HTTP(S) requests — an exfiltration and pivot surface bounded only by the per-script and global concurrency / size caps above, not by destination: any address is reachable, including loopback, link-local (incl. cloud-metadata), private, and ULA ranges, on the physical interface outside the tunnel. Only run rule sets from sources you trust.
  • Failure is safe-by-default. A compile failure, a missing process, or an uncaught throw — including an unhandled Anywhere.http rejection — passes the original message through unchanged.

Worked examples

Inject a request header on API paths

name     = Add Trace
hostname = api.example.com
0, 1, ^/v2/, X-Trace-Id, anywhere

Redirect an old path, preserving the tail (transparent URL rewrite)

name     = Path Migration
hostname = example.com
0, 0, ^https://example\.com/old/(.*), 0, https://example.com/new/$1

Block a host with a 1×1 GIF

name     = Block Tracker
hostname = tracker.example.com
0, 0, .*, 3

Edit a JSON response body (script)

Source (flag.js):

function process(ctx) {
  try {
    const obj = JSON.parse(Anywhere.codec.utf8.decode(ctx.body));
    obj.vip = true;
    ctx.body = Anywhere.codec.utf8.encode(JSON.stringify(obj));
  } catch (e) {
    Anywhere.log.warning("not JSON: " + e);
  }
}

Encode and author:

printf '%s' "$(cat flag.js)" | base64
# → eyAuLi4gfQ==   (example)
name     = VIP Flag
hostname = api.example.com
1, 100, ^/v1/profile, eyAuLi4gfQ==

Mock an endpoint without hitting upstream (Anywhere.respond)

function process(ctx) {
  Anywhere.respond({
    status: 200,
    headers: [["Content-Type", "application/json"]],
    body: '{"enabled":true}'
  });
}
0, 100, ^/api/feature-flags, <base64>

Enrich a response with a second request (Anywhere.http)

process is async so it can await a fetch. Here it pulls a profile from a sidecar API and merges it into the JSON response body, leaving the body unchanged if anything fails.

async function process(ctx) {
  try {
    const obj = JSON.parse(Anywhere.codec.utf8.decode(ctx.body));
    const r = await Anywhere.http.get("https://sidecar.example.com/profile/" + obj.id, {
      headers: [["accept", "application/json"]],
      timeout: 3000
    });
    if (r.status === 200) {
      obj.profile = JSON.parse(Anywhere.codec.utf8.decode(r.body));
      ctx.body = Anywhere.codec.utf8.encode(JSON.stringify(obj));
    }
  } catch (e) {
    Anywhere.log.warning("enrich failed: " + e); // body left unchanged
  }
}
1, 100, ^/api/user, <base64>

Redact tokens in a live SSE stream (stream-script)

function process(ctx) {
  let text = Anywhere.codec.utf8.decode(ctx.body);
  text = text.replace(/Bearer [A-Za-z0-9._-]+/g, "Bearer ***");
  ctx.body = Anywhere.codec.utf8.encode(text);
}
name     = Redact SSE
hostname = api.example.com
1, 101, ^/events, <base64>

Count requests across connections (Anywhere.store)

function process(ctx) {
  const prev = Anywhere.store.getString("count");
  const next = (prev ? parseInt(prev, 10) : 0) + 1;
  try { Anywhere.store.set("count", next.toString()); }
  catch (e) { Anywhere.log.warning("store full: " + e); }
  Anywhere.log.info("request #" + next + " to " + ctx.url);
}
0, 100, .*, <base64>

A script can't add the count as a request header (ctx.headers is read-only); to put a fixed header on the wire use a header-add rule instead.


Behavior reference

  • Content-Encoding. For script rules the body is decompressed before the script runs and re-emitted as identity with Content-Encoding dropped and a fresh Content-Length. stream-script rules see raw, still-compressed frames. A rare concatenated multi-member gzip body is left compressed and forwarded unrewritten rather than risk corrupting it.
  • Accept-Encoding. On an intercepted request, Anywhere clamps the client's Accept-Encoding to the codings it can decode — gzip, deflate, br, identity — dropping any others (notably zstd) so an origin can't select an encoding a body rule couldn't reverse. A body that still arrives in an unsupported Content-Encoding is forwarded unrewritten.
  • HEAD responses. A response to HEAD never carries a body; its framing headers are preserved and a script that writes ctx.body has that write dropped on the wire.
  • Interim 1xx responses. 100 Continue, 103 Early Hints, etc. are not the final response; scripts run only on the final response.
  • Protocol upgrades & tunnels. On an HTTP/1.1 connection a 101 Switching Protocols response, or a 2xx to a CONNECT, turns the connection into an opaque tunnel: both directions drop to verbatim passthrough and no rule or script sees the tunneled bytes (e.g. WebSocket frames). An HTTP/2 CONNECT (including the RFC 8441 extended form for WebSocket-over-h2) can't cross the bridge — it terminates and re-originates HTTP/2 rather than relaying frames, and a tunnel has no request/response to translate. The stream is refused with HTTP_1_1_REQUIRED, the standard signal for the client to retry that request over HTTP/1.1 (a fresh connection negotiating http/1.1), where the tunnel path above applies.
  • Pipelining order. A request-phase Anywhere.respond on a pipelined connection is held until the in-flight response ahead of it finishes, so the client's request/response ordering is preserved.
  • Streaming media + script. A buffered script on text/event-stream, multipart/x-mixed-replace, NDJSON, and similar de-streams the body; the rule still runs but logs a warning recommending stream-script.
  • Fail-closed URL gate. If the request URL can't be determined, every rule's URL gate fails closed (the rule is skipped) rather than firing blind.
  • Regex scope. URL patterns are matched against the whole request URL (https://host/path?query), so they can scope by scheme and host as well as path; they never see the method or HTTP version. The set's hostname suffixes still gate the host first.