From d7bbd20da5cb87b5f88c8fb1c440e100189975d2 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 7 Aug 2026 21:14:18 +0200 Subject: [PATCH] fix(sabr): use page-native BotGuard attestation context --- .../datasource/LocalDomPoTokenGenerator.kt | 77 ++++++++++++------- .../datasource/SabrLocalDomPoTokenUtil.kt | 73 ++++++++++++++++++ .../datasource/LocalDomPoTokenRequestTest.kt | 33 ++++++++ 3 files changed, 154 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenGenerator.kt b/app/src/main/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenGenerator.kt index fab892369..919254c6a 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenGenerator.kt +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenGenerator.kt @@ -22,6 +22,8 @@ internal class LocalDomPoTokenGenerator private constructor( private val tokenWaiters = mutableMapOf() private lateinit var expirationInstant: Instant @Volatile + private var pageEventId: String? = null + @Volatile private var closed = false private fun loadScriptAndInitialize() { @@ -142,6 +144,7 @@ internal class LocalDomPoTokenGenerator private constructor( private fun makeBotguardGetRequest( url: String, + extraHeaders: Map> = emptyMap(), onSuccess: (String) -> Unit, onError: (Throwable) -> Unit, ) { @@ -151,10 +154,10 @@ internal class LocalDomPoTokenGenerator private constructor( ?: throw SabrProtocolException("DownloaderImpl is not initialized") val response = downloader.get( url, - mapOf( - "User-Agent" to listOf(SharedWebViewRuntime.USER_AGENT), - "Accept" to listOf("*/*"), - ), + HashMap(extraHeaders).apply { + put("User-Agent", listOf(attestationContext.userAgent)) + put("Accept", listOf("*/*")) + }, ) if (response.responseCode() != 200) { throw SabrProtocolException( @@ -201,41 +204,56 @@ internal class LocalDomPoTokenGenerator private constructor( } private fun downloadAndRunBotguard() { - makeBotguardServiceRequest( - "https://www.youtube.com/youtubei/v1/att/get?prettyPrint=false", - buildLocalDomAttestationBody(attestationContext), - contentType = "application/json", - extraHeaders = buildLocalDomAttestationHeaders( - attestationContext, - credentialHeaders, - ), - onSuccess = { body -> - try { - val challenge = parseSabrAttChallengeData(body) - val inlineInterpreter = challenge.interpreterJavascript - if (inlineInterpreter != null) { - runBotguard(challenge, inlineInterpreter) - } else { - makeBotguardGetRequest( - requireNotNull(challenge.interpreterUrl), - onSuccess = { runBotguard(challenge, it) }, - onError = ::failInitialization, - ) - } - } catch (error: Throwable) { - failInitialization(error) - } + // Keep the page-native challenge paired with the EVENT_ID from the same response. + makeBotguardGetRequest( + YOUTUBE_HOME_URL, + extraHeaders = HashMap(credentialHeaders).apply { + put("Accept-Language", listOf("en-US,en;q=0.7")) }, + onSuccess = ::handleYoutubePageBody, onError = ::failInitialization, ) } + private fun handleYoutubePageBody(body: String) { + try { + val attestation = parseSabrYoutubePageAttestation(body) + pageEventId = attestation.eventId + handleChallengeBody(attestation.rawChallengeData) + } catch (error: Throwable) { + failInitialization(error) + } + } + + private fun handleChallengeBody(body: String) { + try { + val challenge = parseSabrAttChallengeData(body) + val inlineInterpreter = challenge.interpreterJavascript + if (inlineInterpreter != null) { + runBotguard(challenge, inlineInterpreter) + } else { + makeBotguardGetRequest( + requireNotNull(challenge.interpreterUrl), + onSuccess = { runBotguard(challenge, it) }, + onError = ::failInitialization, + ) + } + } catch (error: Throwable) { + failInitialization(error) + } + } + private fun runBotguard( challenge: SabrAttChallengeData, interpreterJavascript: String, ) { + // The page-native BotGuard program reads this while producing its snapshot. + val pageContext = pageEventId?.let { + "window.yt=window.yt||{};window.yt.config_=window.yt.config_||{};" + + "window.yt.config_.EVENT_ID=" + jsString(it) + ";" + }.orEmpty() runtime.evaluateJavascript( - "pipepipeSabrRunBotguard(" + jsString(sessionId) + ", " + pageContext + "pipepipeSabrRunBotguard(" + jsString(sessionId) + ", " + buildSabrAttChallengeData(challenge, interpreterJavascript) + ");", null, ) { error -> failInitialization(error) } @@ -301,6 +319,7 @@ internal class LocalDomPoTokenGenerator private constructor( private const val TOKEN_TIMEOUT_MS = 30_000L private const val INIT_TIMEOUT_MS = 60_000L private const val REQUEST_KEY = "O43z0dpjhgX20SCx4KAo" + private const val YOUTUBE_HOME_URL = "https://www.youtube.com/" @Throws(SabrProtocolException::class) fun create( diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrLocalDomPoTokenUtil.kt b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrLocalDomPoTokenUtil.kt index 17a6cff50..01d30ee4c 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrLocalDomPoTokenUtil.kt +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrLocalDomPoTokenUtil.kt @@ -12,6 +12,28 @@ internal data class SabrAttChallengeData( val interpreterUrl: String?, ) +internal data class SabrYoutubePageAttestation( + val eventId: String, + val rawChallengeData: String, +) + +internal fun parseSabrYoutubePageAttestation(pageHtml: String): SabrYoutubePageAttestation { + val eventId = EVENT_ID_PATTERN.find(pageHtml)?.groupValues?.get(1) + ?: throw IllegalArgumentException("YouTube page has no EVENT_ID") + val call = YT_AT_N_PATTERN.find(pageHtml) + ?: throw IllegalArgumentException("YouTube page has no initial attestation call") + val responseProperty = YT_AT_N_RESPONSE_PATTERN.find(pageHtml, call.range.last + 1) + ?: throw IllegalArgumentException("YouTube page attestation has no response payload") + val quote = responseProperty.groupValues[1].single() + val rawChallengeData = decodeJavascriptString( + pageHtml, + responseProperty.range.last + 1, + quote, + ) + parseSabrAttChallengeData(rawChallengeData) + return SabrYoutubePageAttestation(eventId, rawChallengeData) +} + internal fun parseSabrAttChallengeData(rawAttestationData: String): SabrAttChallengeData { val challenge = JsonParser.`object`().from(rawAttestationData).getObject("bgChallenge") val interpreterJavascript = challenge.getObject("interpreterJavascript") @@ -85,3 +107,54 @@ private fun base64ToByteArray(base64: String): ByteArray { .replace('.', '=') return Base64.getDecoder().decode(normalized) } + +private fun decodeJavascriptString(source: String, start: Int, quote: Char): String { + val result = StringBuilder() + var index = start + while (index < source.length) { + val character = source[index++] + if (character == quote) { + return result.toString() + } + if (character != '\\') { + result.append(character) + continue + } + require(index < source.length) { "Incomplete JavaScript string escape" } + when (val escaped = source[index++]) { + 'b' -> result.append('\b') + 'f' -> result.append('\u000C') + 'n' -> result.append('\n') + 'r' -> result.append('\r') + 't' -> result.append('\t') + 'v' -> result.append('\u000B') + 'x' -> { + result.append(readJavascriptHex(source, index, 2).toChar()) + index += 2 + } + 'u' -> { + result.append(readJavascriptHex(source, index, 4).toChar()) + index += 4 + } + '\n' -> Unit + '\r' -> if (index < source.length && source[index] == '\n') index++ + else -> result.append(escaped) + } + } + throw IllegalArgumentException("Unterminated JavaScript string") +} + +private fun readJavascriptHex(source: String, start: Int, length: Int): Int { + require(start + length <= source.length) { "Incomplete hexadecimal escape" } + var value = 0 + repeat(length) { offset -> + val digit = source[start + offset].digitToIntOrNull(16) + ?: throw IllegalArgumentException("Invalid hexadecimal escape") + value = value * 16 + digit + } + return value +} + +private val EVENT_ID_PATTERN = Regex("\\\"EVENT_ID\\\"\\s*:\\s*\\\"([A-Za-z0-9_-]+)\\\"") +private val YT_AT_N_PATTERN = Regex("""window\.ytAtN\s*\(""") +private val YT_AT_N_RESPONSE_PATTERN = Regex("""['"]R['"]\s*:\s*(['"])""") diff --git a/app/src/test/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenRequestTest.kt b/app/src/test/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenRequestTest.kt index 622e76be3..85ad4f0de 100644 --- a/app/src/test/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenRequestTest.kt +++ b/app/src/test/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenRequestTest.kt @@ -2,6 +2,7 @@ package org.schabi.newpipe.player.datasource import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test @@ -38,6 +39,38 @@ class LocalDomPoTokenRequestTest { ) } + @Test + fun parsesPageNativeAttestationAndEventId() { + val challenge = """{"bgChallenge":{"program":"program","globalName":"global","interpreterJavascript":{"privateDoNotAccessOrElseSafeScriptWrappedValue":"script"}}}""" + val escapedChallenge = challenge.toByteArray().joinToString("") { + "\\x%02x".format(it.toUByte().toInt()) + } + val page = """""" + + assertEquals( + SabrYoutubePageAttestation("event_123-abc", challenge), + parseSabrYoutubePageAttestation(page), + ) + } + + @Test + fun rejectsPageAttestationWithoutEventId() { + val page = """""" + + assertThrows(IllegalArgumentException::class.java) { + parseSabrYoutubePageAttestation(page) + } + } + + @Test + fun rejectsPageAttestationWithoutInitialChallenge() { + val page = """""" + + assertThrows(IllegalArgumentException::class.java) { + parseSabrYoutubePageAttestation(page) + } + } + @Test fun cacheIdentityDoesNotCrossClientContexts() { assertNotEquals(