Anywhere can intercept the HTTP traffic of selected domains — terminating TLS for HTTPS, or reading plain HTTP directly — then inspect, rewrite, and forward it upstream: a man-in-the-middle on traffic you control. This guide covers authoring rules and scripts. It assumes familiarity with HTTP, regular expressions, and JavaScript, and 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 handshake to the minted leaf fails); this is expected. Plain HTTP carries no certificate and needs no CA trust.
- How it works
- Rule sets
- The import format
- Rule operations
- Scripting:
script - Scripting:
stream-script - Execution model
- The
ctxobject - The
AnywhereAPI - Subscriptions
- Single-rule semantics
- Limits and safety
- Worked examples
- Behavior reference
- Code map
Interception is gated on the host. HTTPS is intercepted when the TLS
ClientHello SNI matches a rule set's suffixes; plain HTTP is intercepted
when the first request's host matches — the fake-IP-resolved domain when the
connection was routed by fake IP, otherwise the request's authority
(absolute-form target, or the Host header). MITM must be enabled by the master
toggle, and the matching rule set must be enabled.
An intercepted HTTPS connection is handled in four steps:
- Mint a leaf certificate for the requested host (cached, per host) and
complete the inner TLS handshake with the client, negotiating ALPN from
the client's own offer (intersected with
h2/http/1.1). - Read the first request and apply the matching rules. A
rewriterule can answer on the inner leg (302 / reject — no upstream) or change the destination host. - Defer opening the outer leg until the destination is known, then dial it — the rewritten host when set, otherwise the original — and run its own TLS handshake. An HTTP/1.1 client goes to an HTTP/1.1 upstream; an HTTP/2 client is bridged to whatever the upstream speaks: HTTP/2 directly, or HTTP/1.1 with on-the-fly translation (one short-lived upstream connection per request stream).
- Decrypt each direction, run the matching rules, and re-encrypt to the opposite leg.
Plain HTTP follows the same pipeline without certificates or handshakes: the
upstream is dialed in cleartext, and ctx.url carries an http:// scheme.
Cleartext is always treated as HTTP/1.1 — h2c is not intercepted (prior-knowledge
h2c is forwarded untouched; an Upgrade-based 101 becomes an opaque tunnel,
see Behavior reference).
Traffic is processed in two phases:
- Request (
httpRequest) — client→server, before the request leaves for upstream. - Response (
httpResponse) — server→client, before the response reaches the client.
Both HTTP/1.1 and HTTP/2 are supported. For HTTP/2 the rewriter works on decoded header lists and whole-body buffers; for HTTP/1.1 it drives a byte-level framing state machine. The rule model is identical either way.
A rule set's hostname suffixes gate which hosts are intercepted; each
rule's url-pattern gates which requests within those hosts it acts on. A
request that matches no rule is forwarded unchanged (its body streamed through
unbuffered), so an intercepted-but-unrewritten request is cheap — but
interception itself (the extra TLS handshakes) is not. Scope hostname as
tightly as you can.
Performance note. All script execution runs on a single serial queue, and a single JavaScript VM is shared process-wide — across every rule set and connection. A script that loops forever, recurses without bound, or triggers catastrophic regex backtracking stalls every other script, not just its own connection: CPU-bound execution can't be preempted. A watchdog crashes and relaunches the extension after a ~30 s synchronous span, so a runaway self-recovers rather than wedging for the process's life, but keep scripts bounded. Awaiting an
Anywhere.httpfetch is the exception — the connection parks while the shared runtime stays free for other scripts; only CPU-bound work monopolizes it.
A rule set is the unit of configuration:
| Field | Meaning |
|---|---|
name |
Display name. |
domainSuffixes |
Hosts to intercept, matched by suffix. example.com covers www.example.com. No wildcards. |
enabled |
Per-set toggle; a disabled set is skipped but keeps its data. |
rules |
Ordered list of rewrite rules. Redirect / reject / host-rewrite are per-rule via the rewrite operation. |
parameters |
Optional user-editable values, exposed read-only to scripts via Anywhere.params. See Parameter lines. |
Suffix matching is most-specific-wins: with both example.com and
api.example.com configured, a request to api.example.com uses only the
latter. Each connection resolves to exactly one rule set, once, at connection
start — a transparent rewrite to another host does not re-resolve it. Duplicate
suffixes across sets are last-writer-wins.
Rule sets are created three ways: built rule-by-rule in the app, imported
from a .amrs file, or subscribed to a .amrs URL (see
Subscriptions). The .amrs text format below is the
interchange and authoring format; sets are stored internally as JSON and
exported to the Network Extension as a compact binary. A set holds at most
10,000 rules and 256 parameters.
A rule set is a sequence of header lines, rule lines, and (optionally)
parameter 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/parameter is dropped silently, so a partially
valid file still imports what it can.
Lines belong to a section introduced by a bracketed header. Sections are
optional and a file starts in [Rule]. [Parameter] begins the parameter
section; an unrecognized bracket header makes following lines ignored until the
next section.
# 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
Shape: <key> = <value>. Keys are case-insensitive; the value is trimmed and
otherwise kept verbatim. Header lines are recognized in any section.
| Key | Meaning |
|---|---|
name |
Display name. |
hostname |
Comma-separated domain suffixes. |
icon-light |
Base64-encoded image bytes for the icon shown in light appearance. |
icon-dark |
Base64-encoded image bytes for the icon shown in dark appearance. |
Lines with unrecognized keys are dropped. Redirect / reject / host-rewrite are
configured per-rule via the rewrite operation, not
as headers. An icon that is not valid base64 or decodes to more than 256 KB
is dropped; when the current appearance has no icon, the other variant is used.
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 uses a separate 100+ id range. rewrite (op 0) is always
request-phase regardless of the phase column; its second field is a numeric
sub-mode and the rest depend on it — see
rewrite (0). A rule whose field count does not
match, or whose url-pattern is empty or won't compile as a regex, is dropped.
body-replace's search must also compile as a regex; body-json's trailing
fields depend on action; script / stream-script base64 must decode to
syntactically valid UTF-8 JavaScript (checked at import).
Fields are separated by ,. Whitespace around an unquoted field is trimmed. A
field beginning with " is read to the matching ", and "" inside a quoted
field is a literal ". Quote any field containing a comma or significant
leading/trailing whitespace:
0, 1, ^/, X-Note, "value, with a comma"
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); it does not see the method or HTTP
version. Use .* to match every request, or anchor on scheme/host
(^https://api\.example\.com/) to scope by origin — but an intercepted
plain-HTTP URL has an http:// scheme, so anchor on ^https?:// (or just
the host) when a set also covers cleartext. The host is matched
case-insensitively (lowercased before the test); path and query keep their case.
A URL longer than 8 KB never matches, and if the URL can't be determined the
gate fails closed (the rule is skipped).
For response-phase rules the gate is tested against the originating request's URL (response heads carry no path) — specifically its post-rewrite URL — so a request and its response can share a pattern.
Parameters expose a few user-editable values that scripts read at runtime via
Anywhere.params — a country code, a feature flag, a token.
Declare them under [Parameter]; the app renders an editor for each, and the
chosen values are surfaced read-only to the set's scripts.
Shape:
<type>, <data-type>, <name>, <label>, <description>, <default> [, "[<option>, …]"]
| Field | Meaning |
|---|---|
type |
0 = free-text input, 1 = picker. |
data-type |
0 = string. The only type today. |
name |
Lookup key for Anywhere.params.get("<name>"). ASCII letters/digits/_, ≤128 bytes, unique within the set. |
label |
Label shown in the editor. Optional — falls back to name. |
description |
Help text rendered as the editor's footer. Optional. |
default |
Value used until the user changes it. |
"[option, …]" |
A picker's allowed values, as a bracketed list. Ignored for an input. |
A picker's options are the seventh field. Because the list contains commas,
CSV-quote the whole field: "[US, JP, DE]". A picker needs at least one
option; a default not among them is added at the front, and an empty default
falls back to the first option. Duplicate names and malformed lines are dropped.
[Parameter]
# free-text input — empty label/description, empty default
0, 0, token, , ,
# input with a label and a footer description
0, 0, apiKey, API Key, Paste the key from your dashboard.,
# picker: US / JP / DE, default US (note the quotes around the options list)
1, 0, country, Country, Used for region-specific rewrites., US, "[US, JP, DE]"
User values survive a subscription refresh: an override is kept while its parameter still exists (and, for a picker, is still one of the options); otherwise it falls back to the new default.
Its second field is a numeric sub-mode; the rest depend on it. When the
url-pattern gate matches, the first rewrite rule that resolves wins (a
rule that matches but fails to expand its target is skipped, so a later rule can
still win).
| Sub-mode | Name | Args | Effect |
|---|---|---|---|
0 |
transparent | <full-url> |
Replace the whole request URL. The request-target becomes the replacement's path+query (empty path → /, fragment dropped); the outer leg dials the replacement host and Host / :authority is rewritten to match. The client still sees the original host on the leaf certificate. A script reads the rewritten URL as ctx.url, the original as ctx.originalUrl. |
1 |
302 redirect | <full-url> |
Synthesize 302 Found with Location: <full-url> (verbatim). No upstream dial. |
2 |
reject 200 text | [<content>] |
Synthesize 200 OK, text/plain; charset=utf-8. Empty → a short default line. No upstream dial. |
3 |
reject 200 gif | (none) | Synthesize 200 OK with a canned 1×1 image/gif. No upstream dial. |
4 |
reject 200 data | [<base64>] |
Synthesize 200 OK, application/octet-stream, body decoded from <base64>. Empty → a default payload. No upstream dial. |
For sub-modes 0 and 1 the URL must be a full absolute URL with a host
(validated at import). The replacement supports capture references to the
url-pattern match: $0 is the whole match, $1–$9 (and ${10}, ${11}, …)
are groups, $$ is a literal $. A replacement with no reference is used
verbatim. References resolve per request, so one pattern with capture groups
can rewrite many URLs; a non-participating group expands to empty, and if the
expanded URL isn't a valid absolute URL the rule is skipped for that request.
A transparent rewrite can change the dial target, so the upstream dial is deferred: the inner handshake completes, the first request is read and rewritten, and only then is the upstream dialed. Consequences for divergent targets on one connection:
- HTTP/1.1 fixes its single upstream leg on the first request. A later request whose rewrite resolves a different host is reconnected transparently if the leg is idle (no response in flight); if a response is still in flight the connection is torn down and the client retries on a fresh one.
- A bridged HTTP/2 client with an HTTP/1.1 upstream dials per stream, so each stream generally follows its own resolved host. With an HTTP/2 upstream, the connection is committed on the first request and later streams go to that committed upstream regardless of their rewrite.
Either way, avoid splitting one origin's traffic across several transparent target hosts.
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
Appends a header (does not replace an existing one of the same name):
0, 1, .*, X-Trace-Id, anywhere
Removes every header with the given name (case-insensitive):
1, 2, .*, Set-Cookie
Unlike
header-add/header-replace, which reject framing and hop-by-hop header names at load,header-deleteis not guarded — deletingContent-LengthorTransfer-Encodingchanges how the body is framed. Don't.
Overwrites the value of every header with the given name (case-insensitive), and normalizes the name's spelling to the rule's. A header that is not present is left alone — it does not add it:
1, 3, .*, Cache-Control, no-store
Regex find-and-replace over the text body in native code, no JavaScript. Its
fields are url-pattern, 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 matched against the whole decompressed body, and
every match is replaced. replacement supports the same capture references as
rewrite ($0, $1–$9, ${10}, $$); an empty replacement deletes
matches. A rule whose search is empty or won't compile is dropped. Quote a
field containing a comma or leading ", doubling inner quotes — so the literal
"price": is written """price"":".
The body is decoded as UTF-8, with a Latin-1 fallback that accepts any byte
sequence — there is no charset detection. A non-UTF-8 body (UTF-16, GBK, …) is
therefore seen as Latin-1 and round-trips only because nothing matches; a pattern
that does match such a body can corrupt it. Use body-replace on UTF-8 text.
Like script, body-replace is a buffered transform: the body is
accumulated (auto-decoding gzip / deflate / br, up to 4 MiB), edited,
and re-emitted with a fresh Content-Length. The contract is otherwise
total — a search that matches nothing, or a replacement unrepresentable
in the body's bytes, leaves the body unchanged. Every matching body-replace
rule fires, in rule order, so replacements compose. (Body substitutions are
process-serialized: if two run at once, or one exceeds a 1 s soft deadline, that
message is left with its remaining edits unapplied.)
When several body transforms match one message they run in a fixed order:
body-json first, then body-replace, then a script (so the script sees the
fully-edited body).
Declarative JSON body editing in native code — the same edits as the
Anywhere.json script API, without JavaScript. One rule, one
edit; fields are url-pattern, an action token, and the action's fields:
action |
Trailing fields | Effect |
|---|---|---|
add |
path, value |
Upsert at path (create or overwrite; append at array end when index == length). |
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 field ∈ values. |
path is a JSONPath like $.data.items[0].id (leading $ optional; dotted keys
and [index] / ["key"] brackets; an empty path or $ addresses the whole
document). value / values are JSON literals (true, 42, "text",
{"a":1}, ["x","y"]); a string that isn't valid JSON is taken literally,
so value = Anywhere means "Anywhere". Action tokens are case-insensitive and
accept the camelCase spelling. A rule whose path can't be parsed is dropped.
Like script, body-json is a buffered transform (auto-decoding, 4 MiB
cap). The contract is total — a body that isn't JSON, a path that doesn't
resolve, or an unserializable result leaves the body byte-for-byte unchanged.
A successful edit re-serializes the whole document, so object member order is
not preserved and slashes are not escaped; a document nested deeper than ~600
levels, or containing non-finite numbers, is left unchanged. Every matching
body-json rule fires in rule order; a matching script runs after them.
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 containing a comma must be one quoted CSV field with inner
quotes doubled:
1, 5, ^/api/feed, remove-where-field-in, $.items, status, "[""expired"",""deleted""]"
1, 5, ^/api/profile, add, $.meta, "{""beta"":true,""tier"":2}"
1, 5, ^/api/profile, replace, $.tier, "gold, platinum"
1, 5, .*, replace-recursive, access_token, "***"
JavaScript transforms. The field is base64-encoded UTF-8 source defining
function process(ctx). See the next sections.
Use script when 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, and ctx.method / ctx.status aren't
script-writable — so a script rule's job is the body plus the control
directives.
The rewriter buffers the body (auto-decoding gzip / deflate / br), runs
process(ctx) once, and re-emits with a fresh Content-Length. Because nothing
reaches the client until the body is complete, a script 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 async and await an Anywhere.http
request mid-rewrite; the rewriter waits for the Promise to settle before reading
ctx.body back. The connection parks while the fetch is in flight, and the
shared runtime stays free for other connections. stream-script has no such
facility — Anywhere.http is unavailable there.
The body is held up to a 4 MiB cap: a larger Content-Length body falls back
to passthrough (the script is skipped), and a chunked body that reaches the cap
fails closed — the connection is torn down (request) or answered 502
(response) rather than silently truncated.
1, 100, ^/api/user, <base64 of the JS source>
To produce the base64:
printf '%s' "$(cat process.js)" | base64Base64 that doesn't decode to 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).
Use stream-script when the response must keep flowing: Server-Sent Events, NDJSON
or chunked event feeds, gRPC / 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
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 immutable —
ctx.url/ctx.originalUrl/ctx.method/ctx.status/ctx.headersare read-only. - No HTTP-level decompression.
ctx.bodyis the raw frame payload. - No HTTP/1
Content-Lengthbodies — the byte count is already committed, so length-framed 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 a bridged HTTP/2 client
maps to an HTTP/1.1 upstream, a matching
stream-scriptis skipped (body forwarded unscripted, with a warning) in both directions. On the bridge's response path this early exit also skips any bufferedscript/body-replace/body-jsonfor that message; buffered rules still apply on the bridge when the response isContent-Length-framed.
Per-frame context adds:
ctx.frame—{ index, end }: the 0-based frame index and anendflag on the final frame.ctx.state— a JS object shared by every frame of the same stream, and nothing else. It starts{}and is carried forward verbatim between frames, so it is where a stream-script accumulates: a partial line, a running count, a parser position.
1, 101, ^/events, <base64>
Every invocation runs in its own JavaScript context, created when the invocation starts and destroyed when it ends. An invocation is:
| Rule | One invocation spans |
|---|---|
script |
one message — a request, or a response |
script with await |
that same message, extended until the Promise settles |
stream-script |
one stream — every frame of that body |
What it gives a script:
- Fresh intrinsics.
Object.prototype,JSON,Promise,RegExp, and the wholeAnywhereAPI are rebuilt per invocation. A script cannot patch a built-in to watch another's traffic. - A private global. Top-level
var/let/functionbelong to that script alone. - No crosstalk. Two rule sets matching one flow share no objects, prototypes,
or globals. (The single process-wide VM is shared, but each context is
isolated; the one deliberate channel is
Anywhere.store.)
What it costs:
- No state outlives an invocation. A top-level
let cache = {}is empty again on the next message. State that must outlive one message belongs inAnywhere.store; state scoped to the message belongs onctx; state scoped to a stream belongs onctx.state. - Top-level code runs every time. The source is compiled per invocation, so module scope is not a free place to precompute — build lookup tables and regexes inside the branch that needs them.
The one resource genuinely shared between invocations is the runtime's thread: a CPU-bound loop blocks every other script (see Limits and safety).
process(ctx) receives a context object. Scripts read its fields freely, but the
only field read back is ctx.body.
| Field | Type | Phase | Mutable | Notes |
|---|---|---|---|---|
ctx.phase |
"request" / "response" |
both | no | |
ctx.method |
string or null |
both | no | On response, the originating request's method. |
ctx.url |
string or null |
both | no | Absolute URL; reflects a transparent rewrite. On response, the originating request's URL. |
ctx.originalUrl |
string or null |
both | no | The request URL before a transparent rewrite; equal to ctx.url when none matched. |
ctx.status |
number or null |
response | no | null on request. |
ctx.headers |
array of [name, value] |
both | no | Preserves duplicates and order. |
ctx.body |
Uint8Array |
both | yes | A copy of the body bytes; reassign it (to a typed array, ArrayBuffer, or string) or mutate it in place. |
Only ctx.body is mutable, in both script and stream-script (the latter also
reads back ctx.state). Every head field is read-only — assigning it is
ignored on readback. URL and header edits have dedicated rule operations
(rewrite, header-*); method and status have no script-side write at all.
Readback keeps the wire well-formed by construction:
- Only
ctx.bodyis adopted; head-field assignments are ignored, so a script can't inject a malformed request line, status, or header. - An uncaught exception discards mutations and emits the original message
unchanged — unless a directive was already signalled (see below), in which
case the directive wins. Use
try/catch, or signalAnywhere.done()before throwing, to keep partial work.
A global Anywhere object exposes helpers. Byte convention: functions that
take "bytes" accept a Uint8Array, ArrayBuffer, or string (UTF-8 encoded);
functions that return bytes return a Uint8Array.
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, ignoring 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); the outer Content-Encoding is already
auto-decoded for script rules. Compression decode / encode throw on
malformed input or output past the 4 MiB cap; the text codecs (utf8, base64,
base64url, hex) never throw — a bad input yields empty or replacement output.
Schema-free protobuf wire-format codec.
decode(bytes) → [{ field, wire, value }]— flat list in on-wire order (repeated fields appear multiple times); throws on malformed input.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; wire 1 / 5 (fixed64 /
fixed32) are Uint8Array of length 8 / 4 (interpret with a DataView); wire 2
(length-delimited) is a Uint8Array — recurse with decode for nested messages.
Group wire types (3, 4) are rejected.
Hashes and HMAC return raw digest bytes; 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) → Uint8Array—nin[0, 65536]; out-of-range throws.uuid() → string— lowercased.aesGCM.encrypt(spec) → { nonce, ciphertext, tag }andaesGCM.decrypt(spec) → Uint8Array. The spec:key:Uint8Arrayof 16 / 24 / 32 bytes (AES-128/192/256).nonce: exactly 12 bytes; any other length throws. On encrypt, omit it to have a fresh random nonce generated and returned.plaintext/ciphertext: bytes.tag: 16-byteUint8Array(decrypt only).aad: optional additional authenticated data.- decrypt throws a catchable error on auth failure.
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 }.headeris parsed JSON;payloadis parsed JSON or aUint8Arrayfor binary payloads;signatureis bytes;signingInputis theheader.payloadoctet string to recompute the signature over.encode({ header, payload, signature? }) → string. Object header/payload areJSON.stringify'd; bytes/string are used verbatim.
Byte-oriented JSON editing: bytes-in / bytes-out (first arg is the body;
returns a fresh Uint8Array of compact JSON). The contract is total — a body
that isn't JSON, an unresolved path, a type mismatch, or an unserializable value
all yield the body unchanged rather than throwing. A successful edit
re-serializes the whole document, so member order is not preserved and a document
nested deeper than ~600 levels (or containing non-finite numbers) is left
unchanged.
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 namedkeyat any depth (bare key, not a path).delete(body, path)— remove the addressed member/element.deleteRecursive(body, key)— remove every property namedkeyat any depth.removeWhereKeyExists(body, path, key)— at the array atpath, drop objects containingkey.removeWhereFieldIn(body, path, field, values)— at the array atpath, drop objects whosefieldequals one ofvalues(array or scalar).
For these same edits without a script — declared as a rule, run in native code — use
body-json(5). Ascriptis only needed when the edit must be conditional, computed, or combined withAnywhere.respond/ directives.
Per-rule-set key/value state, scoped by rule-set id — and, because every invocation gets a fresh context, the only place script state outlives a single message.
get(key[, onDisk]) → Uint8Array | undefinedgetString(key[, onDisk]) → string | undefinedset(key, value[, onDisk])— value is bytes. Throws when the write would exceed the scope's 1 MiB cap or the 16 MiB process-wide cap (catch it and shed entries withdelete).delete(key[, onDisk])keys([onDisk]) → [string]
Every method is shared across every connection to the same rule set — and
across its script and stream-script rules — and survives a rule-set edit.
Because concurrent invocations share it, treat read-modify-write as racy: another
connection can write between your get and set. State is cleared when the rule
set is removed (a disabled set keeps its data).
The onDisk flag (default false) selects the backing:
onDisk: false— in-memory. Fast, cleared when the extension restarts (tunnel stop, reboot, NE relaunch). For per-session caches.onDisk: true— persisted to a file in the App Group container, so it survives extension restarts. For tokens, cookies, check-in state.
The two backings are separate keyspaces with independent caps; keys()
lists only the backing you ask for. 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]);
}Read-only access to the rule set's parameters, scoped to the running set. Each value is the user's choice, or the declared default.
get(name) → string | undefined— the value, orundefinedif undeclared.keys() → [string]— the declared parameter names.all() → { [name]: string }— everyname → value.
Unlike Anywhere.store, parameters have no setter — they
are configured by the user. An undeclared name reads back undefined, so pair it
with a fallback:
function process(ctx) {
const country = Anywhere.params.get("country") || "US";
Anywhere.log.info("country = " + country);
return ctx;
}info(msg), warning(msg), error(msg), debug(msg) — written through the
shared logger, prefixed [MITM][JS]. debug is compiled out of release
builds; the other three reach both os.log and the user-facing log.
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.
Available in script rules only (not stream-script); 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;urlis a field ofoptions.
Response: { status, headers, body, url }. headers is [[name, value], …],
preserving the origin's order and repeated names; body is a fully-buffered
Uint8Array, best-effort decoded for a gzip / deflate / br response
(with the stale Content-Encoding / Content-Length dropped, Transfer-Encoding
always dropped) — an unsupported or failed coding is handed back still compressed
with its headers intact; url is the final URL after any followed redirects. The
Promise rejects with an Error on a transport failure, timeout, cap breach,
or non-HTTP response; an uncaught rejection reverts the message unchanged.
options:
| Field | Default | Meaning |
|---|---|---|
method |
"GET" / "POST" |
HTTP method. |
headers |
none | [[name, value], …] or { name: value }. Entries with an invalid 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. Accept-Encoding is not settable — the client forces gzip, deflate, br. |
body |
empty | Uint8Array, ArrayBuffer, or string. |
timeout |
10 000 ms | Inactivity timeout (refreshed on each received chunk), clamped to 30 000 ms. An overall ~60 s idle cap and a 180 s absolute budget also apply. |
redirect |
"follow" |
"follow" chases up to 10 hops (301/302/303/307/308; cross-origin hops strip Authorization / Cookie); "manual" returns the 3xx as-is. |
insecure |
global Allow Insecure | A genuine true accepts self-signed server certificates. |
Execution model. A script that awaits a fetch is parked — its connection
waits — but the shared runtime is not blocked: other scripts keep running.
The request is dialed through the tunnel's routing rules (direct / reject / proxy,
like any other connection) as the extension's own outbound traffic, and is not
re-intercepted by the MITM, so a script may call a host the set also intercepts
without looping. It is logged in the request log as a protocol: unknown entry.
Because other invocations run while you await but each has its own context,
nothing your script holds across the suspension can change underneath it — except
Anywhere.store, which is deliberately shared, so re-read a
store value after a fetch rather than caching it across one.
Security.
Anywhere.httpfollows the tunnel's routing rules — arejectrule blocks the fetch, aproxyrule routes it — but there is no SSRF filtering beyond those rules: any host not rejected is reachable, includinglocalhost,*.local, and loopback / link-local (incl. the cloud-metadata address) / private / ULA ranges, which are dialed directly. It is both an exfiltration surface and a pivot into on-device and on-network services. Import rule sets only from sources you trust.
Anywhere.done()— commit the currentctxas this script's result. Because at most onescriptruns per message it does not "skip" other scripts; its use is to lock in mutations before a possible throw, or instream-scriptto emit this frame and pass every later frame through unchanged.Anywhere.exit()— discard this script's own changes: revert to the message as it entered the script (after native body edits), or instream-scriptemit 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 to the client. All fields optional:statusdefaults to 200 (a value outside 100–599, or a non-integer, is replaced with 200),headersto[],bodyto empty. Anywhere owns framing, soContent-Lengthand hop-by-hop headers you set are dropped and aDateis stamped when absent. Ignored (with a warning) on the response phase and instream-script.
Signal a directive and return immediately after.
A subscription is a .amrs file served over http(s) from a URL whose path
ends in .amrs. Anywhere fetches it (HTTP 2xx, UTF-8 body required), parses it
with the format above, and stores it as a rule set.
On refresh, the suffixes, rules, parameters, and icons are replaced wholesale by the fetched file. The set's local name, enabled state, and subscription URL are preserved, as are parameter overrides that remain valid (see Parameter lines). The 10,000-rule cap applies; a file that exceeds it is rejected in full. Imported (non-subscribed) sets are edited in the app rule-by-rule.
At most one script and one stream-script fire per message, by design.
When several rules of the same kind match a URL, the last in rule order wins.
When both a script and a stream-script match, stream-script wins.
Consolidate composed behavior into one process(ctx) rather than splitting it
across rules. Static operations (rewrite, header-*) and buffered body rules
(body-replace, body-json) are not capped — all matching ones apply, in
order.
| Limit | Value | Effect on exceed |
|---|---|---|
| Rules per rule set | 10,000 | import / subscription / refresh rejected; in-app add blocked |
| Parameters per rule set | 256 | extra parameter lines dropped at parse |
| Icon size (decoded) | 256 KB | icon dropped |
Buffered body (script / body-replace / body-json) |
4 MiB | Content-Length → passthrough; chunked → fails closed (connection closed / 502) |
Per-scope Anywhere.store (memory / onDisk) |
1 MiB each | set throws |
Total Anywhere.store (memory / onDisk) |
16 MiB each | set throws |
Anywhere.crypto.randomBytes |
64 KiB | throws |
| Synthesized response body | 4 MiB | truncated |
Anywhere.http timeout |
10 s idle default / 30 s max, ~60 s absolute | Promise rejects |
Anywhere.http per script |
4 concurrent / 16 total | Promise rejects |
Anywhere.http concurrent (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 |
Anywhere.http redirects |
10 hops | Promise rejects |
| HTTP/1 request/response head | 64 KiB | connection closed (request) / 502 (response) |
Body bytes pinned by suspended async scripts (all) |
16 MiB | new flow passes through unmodified |
| Contexts pinned by unsettled scripts (all) | 32 | new flow passes through unmodified; new stream runs unscripted |
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:
- Isolation. Each invocation runs in its own JavaScript context, so a script
cannot reach another's globals or patch a shared built-in. The boundary is per
invocation — two connections running the same rule set are isolated too.
Anywhere.storeis the one deliberate channel. - Wire safety. Header names, values, methods, and request targets produced by
scripts and rules are validated; CR/LF/NUL and other smuggling vectors are
rejected. (The exception noted above:
header-deleteis not guarded against framing headers.) - Watchdogs. Idle async: a suspended
asyncscript that stops making progress is reverted and released after ~60 s. Runaway sync: a CPU-bound loop or pathological regex can't be preempted, so it wedges its own connection and the scripts queued behind it — but a synchronous span past a ~30 s hard cap crashes the extension so the OS relaunches it clean. Keep loops and regexes bounded. - Failure is safe-by-default. A compile failure, a missing
process, or an uncaught throw (including an unhandledAnywhere.httprejection) passes the original message through unchanged.
name = Add Trace
hostname = api.example.com
0, 1, ^/v2/, X-Trace-Id, anywhere
name = Path Migration
hostname = example.com
0, 0, ^https://example\.com/old/(.*), 0, https://example.com/new/$1
name = Block Tracker
hostname = tracker.example.com
0, 0, .*, 3
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);
}
}printf '%s' "$(cat flag.js)" | base64name = VIP Flag
hostname = api.example.com
1, 100, ^/v1/profile, <base64>
function process(ctx) {
Anywhere.respond({
status: 200,
headers: [["Content-Type", "application/json"]],
body: '{"enabled":true}'
});
}0, 100, ^/api/feature-flags, <base64>
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>
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>
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.headersis read-only); use aheader-addrule for a fixed header.
- Content-Encoding. For
scriptrules the body is decompressed before the script runs and re-emitted as identity withContent-Encodingdropped and a freshContent-Length.stream-scriptrules see raw, still-compressed frames. A concatenated multi-membergzipbody — or one truncated or corrupt past its first member — is left compressed and forwarded unrewritten. - Transfer-Encoding content-codings. A body whose
Transfer-Encodingcarries a content-coding (gzip, chunked, or a baregzipframed by connection close) is forwarded verbatim: the buffered path decodes onlyContent-Encodingand thechunkedframing. A framing header injected by a header rule is rejected at load, and any that slips through is dropped at emission, so a rule can't create theTransfer-Encoding+Content-Lengthpair that inbound messages are rejected for (RFC 9112 §6.3.3) — except viaheader-delete, which is unguarded. - Accept-Encoding. Anywhere clamps the client's
Accept-Encodingto the codings it can decode (gzip,deflate,br,identity) only when a response-phase body rule matches the request; a passthrough request forwards it untouched. A body that still arrives in an unsupportedContent-Encodingis forwarded unrewritten. - HEAD responses. A response to
HEADnever carries a body; a script'sctx.bodywrite is dropped on the wire. - Interim 1xx responses.
100 Continue,103 Early Hints, etc. are forwarded as-is; scripts run only on the final response. - Protocol upgrades & tunnels. On HTTP/1.1 a
101 Switching Protocols, or a2xxtoCONNECT, turns the connection into an opaque tunnel: both directions drop to verbatim passthrough and no rule sees the tunneled bytes. An HTTP/2CONNECTcan't cross the bridge — a classic CONNECT is refused withHTTP_1_1_REQUIRED(the client retries over HTTP/1.1, where the tunnel path applies) and the RFC 8441 extended form is refused withPROTOCOL_ERROR. - Pipelining order. A request-phase
Anywhere.respondon a pipelined connection is held until the in-flight response ahead of it finishes, so the client's ordering is preserved. - Streaming media +
script. A buffered body rule ontext/event-streamand similar de-streams the response; the rule still runs but logs a warning recommendingstream-script(HTTP/1 response path only). - Fail-closed URL gate. If the request URL can't be determined, every rule's URL gate is skipped rather than firing blind.
- Regex scope. URL patterns match the whole request URL, so they scope by
scheme and host as well as path; they never see the method or HTTP version, and
the set's
hostnamesuffixes gate the host first. - Scripts never change the head. In both
scriptandstream-script, onlyctx.bodyis adopted — header/method/URL/status edits come from rules, not scripts. On the HTTP/2 legs even a response-phaseAnywhere.respondis ignored.
| Area | Location |
|---|---|
.amrs parsing (rules, params, sub-modes) |
Anywhere/Views/Pages/MITM/MITMRuleSetParser.swift |
| Import / subscribe UI | Anywhere/Views/Pages/MITM/MITMView.swift |
Rule / operation / parameter model, binary payload (AMR1) |
Shared/Models/MITMRule.swift |
| Rule-set storage, 10,000-rule cap, refresh merge | Shared/DataStore/MITMRuleSetStore.swift |
| Subscription refresh | Shared/Operations/MITMRuleSetOperations.swift |
| Host gating, suffix trie, rule compilation | Anywhere Network Extension/MITM/MITMRewritePolicy.swift |
| TLS interception, leaf mint, deferred dial | Anywhere Network Extension/MITM/MITMSession.swift, MITMLeafCertCache.swift |
| HTTP/1.1 framing & rewrite | Anywhere Network Extension/MITM/MITMHTTP1Stream.swift |
| HTTP/2 rewrite & bridge | Anywhere Network Extension/MITM/MITMHTTP2Rewriter.swift, MITMBridgeClientLeg.swift, MITMHTTP2UpstreamLeg.swift |
rewrite sub-modes, capture templates, gate regex |
MITMRewritePolicy.swift, MITMCaptureTemplate.swift, MITMGateRegex.swift |
body-replace / body-json native transforms |
MITMBodyReplace.swift, MITMJSONPatch.swift |
Synthesized 302 / reject / respond |
MITMRespondBuilder.swift, MITMSynthesizedResponse.swift |
Script engine, ctx, Anywhere API, watchdogs |
MITMScriptEngine.swift, MITMScriptTransform.swift, MITMScriptWatchdog.swift |
Anywhere.store backings |
MITMScriptStore.swift, MITMScriptDiskStore.swift |
Anywhere.http fetch path |
Anywhere Network Extension/MITM/Anywhere.http/ |
| Body (de)compression | MITMBodyCodec.swift |