Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ android {
applicationId = "com.vauth.foxyvpn"
minSdk = 26
targetSdk = 35
versionCode = 44
versionName = "1.0.7"
versionCode = 45
versionName = "1.0.8"

externalNativeBuild {
ndkBuild {
Expand Down
7 changes: 7 additions & 0 deletions app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,10 @@
-dontwarn com.google.errorprone.annotations.**

-dontwarn reactor.blockhound.**

-keepattributes JavascriptInterface, *Annotation*

-keep class com.vauth.foxyvpn.ui.screens.WebChannelBridge { *; }
-keepclassmembers class * {
@android.webkit.JavascriptInterface <methods>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import javax.net.SocketFactory

private const val TAG = "ControlPlaneHttp"

const val MOZILLA_VPN_USER_AGENT = "MozillaVPN/2.35.0 (sys:linux; iap:true)"
const val MOZILLA_VPN_USER_AGENT = "MozillaVPN/2.35.0 (sys:android 14; iap:true)"

fun Request.Builder.applyMozillaVpnHeaders(): Request.Builder {
header("User-Agent", MOZILLA_VPN_USER_AGENT)
Expand Down
117 changes: 80 additions & 37 deletions app/src/main/java/com/vauth/foxyvpn/data/FastlyChallengeSolver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,33 @@ private const val HTML_ACCEPT =
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"

private val CHALLENGE_PREFIX_REGEX = Regex("/_fs-ch-[A-Za-z0-9]+")
private val INIT_CALL_REGEX = Regex("init\\((\\[[^\\]]*\\]),\\s*\"([^\"]+)\",\\s*\"([^\"]+)\"")
private val INIT_CALL_REGEX = Regex("init\\((\\[[^\\]]*\\]),\\s*\"([^\"]+)\"\\s*,\\s*\"([^\"]+)\"")

class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) {

private class NoChallengePage : Exception()
suspend fun solveAndInstall(
challengedBody: String? = null,
challengedOrigin: String? = null,
) = withContext(Dispatchers.IO) {
if (!challengedBody.isNullOrEmpty() && looksLikeChallenge(challengedBody)) {
val origin = challengedOrigin?.takeIf { it.startsWith("https://") }
?: baseOriginFromPrefix(challengedBody)
?: throw FastlyChallengeError("challenge page did not contain a usable origin")
solveChallengePage(challengedBody, origin)
return@withContext
}

suspend fun solveAndInstall() = withContext(Dispatchers.IO) {
var lastError: Exception? = null
for (base in listOf("https://api.accounts.firefox.com", "https://accounts.firefox.com")) {
try {
solveOnHost(base)
return@withContext
} catch (_: NoChallengePage) {
continue
} catch (e: FastlyChallengeError) {
lastError = e
for (userAgent in listOf(MOZILLA_VPN_USER_AGENT, SOLVER_USER_AGENT)) {
try {
val page = fetchPage(newSolverClient(SimpleCookieJar()), base + "/", userAgent)
if (!looksLikeChallenge(page)) continue
solveChallengePage(page, base, userAgent)
return@withContext
} catch (e: FastlyChallengeError) {
lastError = e
}
}
}
throw lastError ?: FastlyChallengeError("host did not serve a Fastly challenge page")
Expand All @@ -53,6 +64,11 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) {
return if (idx > 0) prefixUrl.substring(0, idx) else prefixUrl
}

private fun baseOriginFromPrefix(html: String): String? {
val absolute = Regex("https://[A-Za-z0-9.-]+/_fs-ch-[A-Za-z0-9]+").find(html)?.value
return absolute?.let { baseOrigin(it) }
}

private fun solvePow(base: String, targetHex: String): String? {
val target = try {
targetHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
Expand All @@ -71,13 +87,24 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) {
return null
}

private fun fetchPage(client: OkHttpClient, url: String, userAgent: String): String {
val request = Request.Builder().url(url)
.header("User-Agent", userAgent)
.header("Accept", HTML_ACCEPT)
.build()
client.newCall(request).execute().use { response ->
return response.body?.string().orEmpty()
}
}

private fun fetchText(
client: OkHttpClient,
url: String,
userAgent: String,
accept: String = HTML_ACCEPT,
): String {
val request = Request.Builder().url(url)
.header("User-Agent", SOLVER_USER_AGENT)
.header("User-Agent", userAgent)
.header("Accept", accept)
.build()
client.newCall(request).execute().use { response ->
Expand All @@ -86,10 +113,12 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) {
}
}

private fun fetchChallengePage(client: OkHttpClient, pageUrl: String): Pair<String, Boolean> {
val body = fetchText(client, pageUrl)
val isChallenge = body.contains("/_fs-ch-") && body.contains("Client Challenge")
return body to isChallenge
private fun looksLikeChallenge(body: String): Boolean {
if (body.length !in 1..2_000_000) return false
if (!CHALLENGE_PREFIX_REGEX.containsMatchIn(body)) return false
return body.contains("Client Challenge", ignoreCase = true) ||
body.contains("script.js") ||
body.contains("fst-post-back")
}

private fun parseChallengeInit(script: String): Pair<JSONArray, String> {
Expand All @@ -105,13 +134,13 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) {
return challenges to last.groupValues[2]
}

private fun fetchPat(client: OkHttpClient, prefixUrl: String, token: String): String {
private fun fetchPat(client: OkHttpClient, prefixUrl: String, token: String, userAgent: String): String {
val patUrl = "$prefixUrl/pat?token=${URLEncoder.encode(token, "UTF-8")}"
val request = Request.Builder()
.url(patUrl)
.header("Accept", "text/plain")
.header("Content-Type", "application/json")
.header("User-Agent", SOLVER_USER_AGENT)
.header("User-Agent", userAgent)
.header("Origin", baseOrigin(prefixUrl))
.post(ByteArray(0).toRequestBody(null))
.build()
Expand Down Expand Up @@ -150,7 +179,13 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) {
put("v", 2)
}

private fun answerChallenge(client: OkHttpClient, prefixUrl: String, token: String, challenge: JSONObject): JSONObject {
private fun answerChallenge(
client: OkHttpClient,
prefixUrl: String,
token: String,
challenge: JSONObject,
userAgent: String,
): JSONObject {
val data = challenge.optJSONObject("data") ?: JSONObject()
return when (val type = challenge.optString("ty")) {
"pow" -> {
Expand All @@ -167,7 +202,7 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) {
}
"pat" -> JSONObject().apply {
put("ty", "pat")
put("auth", fetchPat(client, prefixUrl, token))
put("auth", fetchPat(client, prefixUrl, token, userAgent))
}
"clientmetrics" -> clientMetricsAnswer()
else -> throw FastlyChallengeError("unsupported Fastly challenge type '$type' (captcha cannot be solved automatically)")
Expand All @@ -182,29 +217,28 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) {
}
}

private fun solveOnHost(base: String) {
private fun solveChallengePage(
page: String,
origin: String,
userAgent: String = MOZILLA_VPN_USER_AGENT,
) {
val solverJar = SimpleCookieJar()
val solver = ControlPlaneHttp.client.newBuilder()
.cookieJar(solverJar)
.connectTimeout(SOLVE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(SOLVE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()

val pageUrl = "$base/"
val (page, isChallenge) = fetchChallengePage(solver, pageUrl)
if (!isChallenge) throw NoChallengePage()
for (cookie in cookieJar.snapshotAll()) {
solverJar.set(cookie.name, cookie.value, cookie.domain)
}
val solver = newSolverClient(solverJar)

val prefixMatch = CHALLENGE_PREFIX_REGEX.find(page)
?: throw FastlyChallengeError("challenge asset prefix not found on $base")
val prefixUrl = base + prefixMatch.value
?: throw FastlyChallengeError("challenge asset prefix not found on $origin")
val prefixUrl = origin + prefixMatch.value

val script = fetchText(solver, "$prefixUrl/script.js?reload=true")
val script = fetchText(solver, "$prefixUrl/script.js?reload=true", userAgent)
var (challenges, token) = parseChallengeInit(script)

repeat(MAX_POST_BACK_ROUNDS) {
val answers = JSONArray()
for (i in 0 until challenges.length()) {
answers.put(answerChallenge(solver, prefixUrl, token, challenges.getJSONObject(i)))
answers.put(answerChallenge(solver, prefixUrl, token, challenges.getJSONObject(i), userAgent))
}
val postBody = JSONObject().apply {
put("token", token)
Expand All @@ -214,16 +248,18 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) {
.url("$prefixUrl/fst-post-back")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("User-Agent", SOLVER_USER_AGENT)
.header("Origin", baseOrigin(prefixUrl))
.header("User-Agent", userAgent)
.header("Origin", origin)
.post(postBody)
.build()
solver.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw FastlyChallengeError("challenge post-back returned HTTP ${response.code}")
val body = JSONObject(response.body?.string().orEmpty())
if (body.optString("status") == "success") {
val (_, stillChallenged) = fetchChallengePage(solver, pageUrl)
if (stillChallenged) throw FastlyChallengeError("challenge cookie not accepted on this exit IP")
val recheck = fetchPage(solver, "$origin/", userAgent)
if (looksLikeChallenge(recheck)) {
throw FastlyChallengeError("challenge cookie not accepted on this exit IP")
}
installChallengeCookies(solverJar)
return
}
Expand All @@ -238,4 +274,11 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) {
}
throw FastlyChallengeError("Fastly challenge did not complete within $MAX_POST_BACK_ROUNDS rounds")
}

private fun newSolverClient(solverJar: SimpleCookieJar): OkHttpClient =
ControlPlaneHttp.client.newBuilder()
.cookieJar(solverJar)
.connectTimeout(SOLVE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(SOLVE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
}
Loading
Loading