-
Notifications
You must be signed in to change notification settings - Fork 4
Operator API Security
The Operator API is a trust-bounded extension surface. The client treats every value as untrusted input from the network and applies hard validation before it ever touches UI, theme, or disk.
This doc describes what the client guarantees, what it does not, and what attacks we explicitly defend against.
The relevant attacker classes:
-
Malicious operator — controls the subscription URL response.
- May send hostile headers (oversize values, payloads, malicious URLs).
- Cannot be trusted with branding power alone; we treat them like any other external input.
- Network attacker — intercepts subscription HTTP response on a compromised network (rare, since subscriptions are HTTPS).
- Operator-side image host compromise — operator's logo CDN is taken over and starts serving hostile images / redirects to internal services.
- App-store reviewer / regulator — needs to see that "branding" cannot silently transform the app into a different app (changing identity is limited to what the user sees on screen, not how the app behaves).
Note: an operator who is also the subscription provider can already abuse many things outside this API (proxy choices, routing rules, etc.). Branding just adds a few more knobs — those knobs must still be safe individually.
- Trimmed of leading / trailing whitespace
- Stripped of control characters (
U+0000–U+001F,U+007F, except\n/\r/\twhere the field allows it) - Truncated to the per-field max length (see Header Reference)
- Decoded from
base64:prefix when present - After all of that, blank → field treated as absent
URL fields are accepted only when the value starts with one of:
https://tg://mailto:-
t.me/(auto-promoted tohttps://t.me/)
http:// is rejected for branding URLs (logos, links). The one
exception is metadata probes on subscription URLs themselves, where the
user explicitly opted in to a plaintext subscription.
URLs additionally pass a looksLikeUrl check (length, no embedded
whitespace).
- Regex
^#[0-9A-Fa-f]{6}$only — no#RGB, norgba(), no named colors. - The client computes WCAG luminance against the surface color. If contrast ratio is below 3:1 (the AA "large text" minimum), the color is rejected and the default theme accent is used.
true / 1 / yes / on (case-insensitive) → true.
false / 0 / no / off → false. Anything else → header treated as
absent.
Only values from the documented set are accepted. Unknown values are treated as if the header weren't there.
-
Subscription-Userinfoquota fields: parsed asLong, must be ≥0.expire=timestamp must be in[0, now + 10 years]; outside the window is treated as absent. -
X-Brand-Max-Devices: integer 1–999.
This is the highest-risk field — we fetch arbitrary bytes from a URL chosen by the operator. Defenses:
- HTTPS only. http:// rejected before the request is even built.
-
No redirects to private networks. Before each redirect hop is
followed, the resolved IP is checked against:
10.0.0.0/8172.16.0.0/12192.168.0.0/16127.0.0.0/8-
169.254.0.0/16(link-local) -
::1/128(IPv6 loopback) -
fc00::/7(IPv6 ULA) -
fe80::/10(IPv6 link-local) - Any other RFC1918 / RFC4193 / RFC6890 reserved space
- Connect + read timeouts: 15s each
- Max redirect count: 3
- Bound to the device's own DNS resolver (no system override during fetch)
-
Content-Typemust match one ofimage/png,image/webp,image/jpeg. Anything else (includingimage/svg+xml,image/gif,text/html) is rejected. -
Content-Length(when present) must be ≤ 512KB. If absent, the read is capped at 512KB and any byte beyond aborts the download. - After download, the bytes are parsed by
BitmapFactory.decodeByteArrayin bounds-only mode first, and a second decode only happens ifoutWidth × outHeight × 4 ≤ 4MB(max ~1024×1024 fully decoded). - Animated images (animated WebP) are decoded as the first frame only.
- Logos cached at
<filesDir>/brand/<sha256(url)>(private to the app) - Atomic write:
tmpfile +rename - Purged when:
- The owning subscription is deleted
- A new logo for the same subscription arrives with a different URL
- Never written to external storage, never accessible to other apps
SVG can carry XSS-style payloads (<script>, xlink:href to remote
resources, CSS imports, foreignObject HTML). Even AndroidSVG's safer
parsing isn't a blanket guarantee against denial-of-service via billion-laughs
or geometry explosion. Logos are flag-bitmaps — PNG/WebP cover the use case
without the SVG attack surface.
If a future operator strongly needs vector logos, we'd render them in a sandboxed renderer (e.g. parsed into a restricted set of shapes by us, no arbitrary XML evaluation) — not by piping their SVG into a general parser.
- Does the logo actually represent the operator? No — we don't verify brand ownership. Operators can in principle show a logo from someone else (which would be fraud at the operator level, not our problem).
- Is the operator's website actually theirs? Same.
- Does the brand name match the panel? No — it's free-form.
These are operator-trust concerns, not client-security concerns. The user already chose to trust this subscription by adding it.
By design, branding never silently changes app identity in ways the user can't notice:
- The launcher icon and Android-level package name never change. App store reviewers can always identify the app.
- The About screen always shows the line
<brand> — powered by ClashFest(untranslatable, fixed). The user can always tell what client they're running. - When branding is active, Settings shows a "Reset branding" option that restores the default app identity in one tap.
The cached brand (name, logo, accent, links) is purged in these cases:
- The active profile is removed (and there are no other profiles)
- The user taps "Reset branding" in Settings
- A subsequent fetch from the same subscription returns headers that no longer carry the brand value (clearing branding is an operator-side action, not stuck-forever state)
We do not reset branding on app upgrade — it survives version bumps.
- Logo fetches happen at most once per subscription update cycle
- Failed fetches are remembered for 1h to avoid retry storms
- The "active brand" cache in
SharedPreferencesis the source of truth between updates — losing network does not strip branding mid-session
If you find a security issue in the Operator API or its parser, please follow the security policy at the top of the repository.
📱 User Guide
- Getting Started
- Profiles & Nodes
- Routing & Rules
- Settings
- Deep Links
- Encrypted Subscriptions
- Troubleshooting
🏢 Operator API
📺 Companion