Skip to content

Commit 029bc99

Browse files
errorcodeQQerrorcodeQQ
authored andcommitted
Refactor CSGuard: Implement reflection intent hook, disable AniZen
1 parent 3713b1c commit 029bc99

20 files changed

Lines changed: 1790 additions & 48 deletions

AniZen/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ cloudstream {
1818
iconUrl = "https://anizen.tr/favicon.png"
1919
isCrossPlatform = true
2020
}
21+

CSGuard/build.gradle.kts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
version = 1
2+
3+
cloudstream {
4+
description = "Defensive CloudStream plugin that intercepts and neutralizes malicious browser-redirect ad injections from other extensions. Wraps provider Context objects so raw Intent.ACTION_VIEW calls to ad networks are silently dropped to the void."
5+
authors = listOf("CSGuard")
6+
status = 1
7+
tvTypes = listOf("Others")
8+
requiresResources = false
9+
language = "en"
10+
iconUrl = "https://raw.githubusercontent.com/google/material-design-icons/master/png/communication/security/png48/security_48dp.png"
11+
}
12+
13+
android {
14+
namespace = "com.csguard"
15+
lint { abortOnError = false }
16+
buildFeatures {
17+
buildConfig = true
18+
viewBinding = false
19+
}
20+
}

CSGuard/gradle.properties

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
2+
android.useAndroidX=true
3+
android.enableJetifier=true
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<manifest />
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package com.csguard
2+
3+
/**
4+
* Known ad-network / popunder domains used by malicious CloudStream extensions.
5+
*
6+
* This list is seeded from a real-world analysis of
7+
* malicious extensions, where the constant
8+
*
9+
* OMG10 = "aHR0cHM6Ly9vbWcxMC5jb20vNC8xMTEwNDQ4OQ=="
10+
*
11+
* base64-decodes to `https://omg10.com/4/11104489` — an OMG/PropellerAds-style
12+
* popunder ad-network URL fired via raw `Intent.ACTION_VIEW` from inside
13+
* `MainAPI.loadLinks()` on every Play tap.
14+
*
15+
* The list is intentionally broad: extensions like this rotate ad networks, so
16+
* we block by category rather than just by the single observed domain.
17+
*/
18+
object AdBlockList {
19+
20+
// ---- Hard-blocked hosts (known ad networks / popunders / redirectors) ----
21+
val BLOCKED_HOSTS: Set<String> = setOf(
22+
// Observed in CNCVerse repo
23+
"omg10.com",
24+
"omg1.com", "omg2.com", "omg3.com", "omg4.com", "omg5.com",
25+
"omg6.com", "omg7.com", "omg8.com", "omg9.com",
26+
27+
// PropellerAds / Monetag family
28+
"propellerads.com", "propeller-tracking.com",
29+
"monetag.com", "monetag-handhub.com",
30+
31+
// Adsterra
32+
"adsterra.com", "adsterra.network", "prohitshere.com",
33+
34+
// HilltopAds
35+
"hilltopads.com", "hilltopads.net", "hilltopads-delivery.com",
36+
37+
// PopAds / PopCash
38+
"popads.net", "popcash.net", "popmyads.com",
39+
40+
// Ad-Maven / Maven
41+
"ad-maven.com", "a-mo.net", "mvn.link",
42+
43+
// Shortcutters commonly used by ad-redirect layers
44+
"bit.ly", "t.ly", "cutt.ly", "shorturl.at", "tinyurl.com",
45+
"shrtco.de", "soo.gd", "s.id", "is.gd", "v.gd",
46+
"linkvertise.com", "linkvertise.net",
47+
48+
// Generic popunder / malvertising CDNs
49+
"onclickperformance.com", "onclickprediction.com",
50+
"onclickscript.com", "highperformanceformat.com",
51+
"pushmonetization.com", "realtimepush.com",
52+
"highratecpm.com", "profithighrate.com",
53+
"crazypushsub.com", "push-notification.tools",
54+
"bemobtrcks.com", "bemobpath.com",
55+
"go2cloud.org", "go2affise.com",
56+
"trackings.selfpublishing.com",
57+
"xl-trail.com", "trail_trk.com",
58+
59+
// Suspicious TLD patterns commonly abused
60+
// (will be matched more carefully in code — see isBlocked())
61+
)
62+
63+
// ---- Hosts that are explicitly ALLOWED to open externally ----
64+
//
65+
// CloudStream itself opens these for repo install / legal / community
66+
// purposes, and they are user-initiated actions — not silent ad redirects.
67+
val SAFE_HOSTS: Set<String> = setOf(
68+
// CloudStream infra
69+
"cs.repo", "cloudstream.on.fleek.co",
70+
"recloudstream.github.io", "github.com", "raw.githubusercontent.com",
71+
72+
// Self-promo (user-initiated, in the form of a dialog with a button)
73+
"t.me", "telegram.me",
74+
75+
// Common legitimate support sites
76+
"discord.com", "discord.gg", "patreon.com", "ko-fi.com",
77+
"buymeacoffee.com",
78+
79+
// Search/lookup that user might legitimately want
80+
"www.google.com", "duckduckgo.com",
81+
82+
// Wikipedia / IMDB for metadata
83+
"wikipedia.org", "imdb.com"
84+
)
85+
86+
// ---- URL patterns that are NEVER media stream URLs ----
87+
//
88+
// If an ExtractorLink or PlayInBrowser target matches one of these
89+
// patterns, it's almost certainly an ad/redirect, not a real video.
90+
val NON_MEDIA_PATH_PATTERNS: List<Regex> = listOf(
91+
Regex("/\\d+/(\\d{6,})"), // /4/11104489 style ad zone IDs
92+
Regex("/(popunder|popunderinit)"),
93+
Regex("/(redirect|go|visit|jump)/[a-zA-Z0-9]+"),
94+
Regex("/watch\\?key="),
95+
Regex("click\\?"),
96+
Regex("/aff(_)?id=")
97+
)
98+
99+
/**
100+
* Returns true if the host (or any of its parent domains) is on the
101+
* hard-block list.
102+
*/
103+
fun isHostBlocked(host: String?): Boolean {
104+
if (host.isNullOrBlank()) return false
105+
val h = host.lowercase().trim()
106+
// exact match
107+
if (h in BLOCKED_HOSTS) return true
108+
// suffix match (handles subdomains like ads.omg10.com)
109+
for (blocked in BLOCKED_HOSTS) {
110+
if (h == blocked || h.endsWith(".$blocked")) return true
111+
}
112+
return false
113+
}
114+
115+
/**
116+
* Returns true if the host is explicitly allowed to open externally.
117+
*
118+
* Combines three sources:
119+
* 1. [SAFE_HOSTS] — static baseline of CloudStream-essential hosts
120+
* 2. [AllowlistStore.alwaysAllow] — persistent user-approved hosts
121+
* 3. [AllowlistStore] session allow-once — process-scoped one-shot approvals
122+
*
123+
* Always called from the [GuardPolicy.shouldBlock] hot path, so it must be fast.
124+
*/
125+
fun isHostSafe(host: String?): Boolean {
126+
if (host.isNullOrBlank()) return false
127+
val h = host.lowercase().trim()
128+
// 1. Static baseline
129+
if (h in SAFE_HOSTS) return true
130+
for (safe in SAFE_HOSTS) {
131+
if (h == safe || h.endsWith(".$safe")) return true
132+
}
133+
// 2 & 3. User-managed allowlist (persistent + session)
134+
return try {
135+
AllowlistStore.isAllowed(h)
136+
} catch (_: Throwable) {
137+
// AllowlistStore not initialized yet — be safe, fall back to baseline
138+
false
139+
}
140+
}
141+
142+
/**
143+
* Heuristic: does the URL path look like an ad-redirect path
144+
* rather than a real media file?
145+
*/
146+
fun looksLikeAdPath(url: String?): Boolean {
147+
if (url.isNullOrBlank()) return false
148+
return NON_MEDIA_PATH_PATTERNS.any { it.containsMatchIn(url) }
149+
}
150+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package com.csguard
2+
3+
import android.content.Context
4+
import android.content.SharedPreferences
5+
import android.util.Log
6+
import org.json.JSONArray
7+
import org.json.JSONObject
8+
9+
/**
10+
* Persistent, user-managed allowlist of hosts that CSGuard will NOT block,
11+
* even in STRICT mode.
12+
*
13+
* Backed by SharedPreferences so it survives plugin reloads and app restarts.
14+
*
15+
* Two scopes:
16+
* - **Always-allow** hosts: persisted across all future sessions.
17+
* - **Allow-once** hosts: kept only for the current process; cleared on
18+
* plugin reload. Useful for one-off approvals without permanently
19+
* whitelisting a host.
20+
*
21+
* Combined with the static [AdBlockList.SAFE_HOSTS] (the built-in baseline
22+
* of CloudStream-essential hosts), the effective allowlist at any moment is:
23+
*
24+
* SAFE_HOSTS ∪ alwaysAllowHosts ∪ sessionAllowOnceHosts
25+
*/
26+
object AllowlistStore {
27+
28+
private const val TAG = "CSGuard"
29+
private const val PREFS_NAME = "csguard_allowlist"
30+
private const val KEY_ALWAYS = "always_allow_hosts"
31+
private const val KEY_BLOCKED = "blocked_attempts_log"
32+
private const val MAX_BLOCKED_LOG = 200
33+
34+
@Volatile private var prefs: SharedPreferences? = null
35+
36+
// In-memory session-only allow-once set (process-scoped, not persisted)
37+
private val sessionAllowOnce = java.util.Collections.synchronizedSet(HashSet<String>())
38+
39+
fun init(context: Context) {
40+
if (prefs != null) return
41+
prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
42+
Log.i(TAG, "AllowlistStore initialized — alwaysAllow=${alwaysAllow().size} hosts")
43+
}
44+
45+
// ───────────────────── Always-allow (persistent) ─────────────────────
46+
47+
fun alwaysAllow(): Set<String> {
48+
val raw = prefs?.getString(KEY_ALWAYS, "[]") ?: "[]"
49+
return try {
50+
val arr = JSONArray(raw)
51+
(0 until arr.length()).map { arr.getString(it).lowercase().trim() }.toSet()
52+
} catch (_: Throwable) { emptySet() }
53+
}
54+
55+
fun addAlwaysAllow(host: String): Boolean {
56+
val normalized = host.lowercase().trim()
57+
if (normalized.isEmpty()) return false
58+
val current = alwaysAllow().toMutableSet()
59+
if (!current.add(normalized)) return false
60+
prefs?.edit()?.putString(KEY_ALWAYS, JSONArray(current.toList()).toString())?.apply()
61+
Log.i(TAG, "AllowlistStore: added always-allow → $normalized")
62+
return true
63+
}
64+
65+
fun removeAlwaysAllow(host: String): Boolean {
66+
val normalized = host.lowercase().trim()
67+
val current = alwaysAllow().toMutableSet()
68+
if (!current.remove(normalized)) return false
69+
prefs?.edit()?.putString(KEY_ALWAYS, JSONArray(current.toList()).toString())?.apply()
70+
Log.i(TAG, "AllowlistStore: removed always-allow → $normalized")
71+
return true
72+
}
73+
74+
// ───────────────────── Allow-once (session-only) ─────────────────────
75+
76+
fun allowOnce(host: String) {
77+
sessionAllowOnce.add(host.lowercase().trim())
78+
Log.i(TAG, "AllowlistStore: allow-once (session) → $host")
79+
}
80+
81+
fun clearSessionAllowOnce() {
82+
sessionAllowOnce.clear()
83+
}
84+
85+
// ───────────────────── Effective allowlist check ─────────────────────
86+
87+
fun isAllowed(host: String?): Boolean {
88+
if (host.isNullOrBlank()) return false
89+
val h = host.lowercase().trim()
90+
if (h in sessionAllowOnce) return true
91+
val always = alwaysAllow()
92+
if (h in always) return true
93+
// suffix match for subdomains
94+
for (allowed in always) {
95+
if (h.endsWith(".$allowed")) return true
96+
}
97+
return false
98+
}
99+
100+
// ───────────────────── Blocked log (for review) ─────────────────────
101+
102+
data class BlockedEntry(
103+
val url: String,
104+
val caller: String,
105+
val timestamp: Long
106+
)
107+
108+
fun blockedLog(): List<BlockedEntry> {
109+
val raw = prefs?.getString(KEY_BLOCKED, "[]") ?: "[]"
110+
return try {
111+
val arr = JSONArray(raw)
112+
(0 until arr.length()).map { idx ->
113+
val obj = arr.getJSONObject(idx)
114+
BlockedEntry(
115+
url = obj.optString("url"),
116+
caller = obj.optString("caller"),
117+
timestamp = obj.optLong("ts")
118+
)
119+
}
120+
} catch (_: Throwable) { emptyList() }
121+
}
122+
123+
fun recordBlocked(url: String, caller: String) {
124+
val current = blockedLog().toMutableList()
125+
current.add(0, BlockedEntry(url, caller, System.currentTimeMillis()))
126+
// Trim to MAX_BLOCKED_LOG, keep most recent
127+
val trimmed = current.take(MAX_BLOCKED_LOG)
128+
val arr = JSONArray()
129+
for (entry in trimmed) {
130+
val obj = JSONObject()
131+
obj.put("url", entry.url)
132+
obj.put("caller", entry.caller)
133+
obj.put("ts", entry.timestamp)
134+
arr.put(obj)
135+
}
136+
prefs?.edit()?.putString(KEY_BLOCKED, arr.toString())?.apply()
137+
}
138+
139+
fun clearBlockedLog() {
140+
prefs?.edit()?.remove(KEY_BLOCKED)?.apply()
141+
}
142+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.csguard
2+
3+
/**
4+
* Thin shim around [AllowlistStore.recordBlocked] so that the
5+
* [InstrumentationHook] can record blocks without caring about persistence.
6+
*
7+
* Kept as a separate object for clarity and to make it easy to swap in a
8+
* different sink (file logger, remote logger, etc.) later.
9+
*/
10+
object BlockedLog {
11+
@JvmStatic
12+
fun record(url: String, caller: String) {
13+
try {
14+
AllowlistStore.recordBlocked(url, caller)
15+
} catch (_: Throwable) {
16+
// Persistence must never break the block path
17+
}
18+
}
19+
20+
@JvmStatic
21+
fun all(): List<AllowlistStore.BlockedEntry> =
22+
try { AllowlistStore.blockedLog() } catch (_: Throwable) { emptyList() }
23+
24+
@JvmStatic
25+
fun clear() {
26+
try { AllowlistStore.clearBlockedLog() } catch (_: Throwable) {}
27+
}
28+
}

0 commit comments

Comments
 (0)