fix(diagnostics): configure production ingestion safely

This commit is contained in:
2026-07-15 02:36:21 +02:00
parent 24e14565b3
commit 502d724743
4 changed files with 269 additions and 38 deletions

View File

@@ -0,0 +1,200 @@
package com.vnidrop.app.diagnostics
private const val MaxDiagnosticsAcknowledgementBytes = 16 * 1024
private const val MaxDiagnosticsAcknowledgementDepth = 32
internal fun String.isSuccessfulDiagnosticsAcknowledgement(expectedId: String): Boolean {
if (length > MaxDiagnosticsAcknowledgementBytes || encodeToByteArray().size > MaxDiagnosticsAcknowledgementBytes) return false
return runCatching {
DiagnosticsAcknowledgementParser(this).isSuccessful(expectedId.lowercase())
}.getOrDefault(false)
}
private class DiagnosticsAcknowledgementParser(private val source: String) {
private var index = 0
fun isSuccessful(expectedId: String): Boolean {
skipWhitespace()
expect('{')
skipWhitespace()
val keys = mutableSetOf<String>()
var ok: Boolean? = null
var id: String? = null
if (!consume('}')) {
while (true) {
val key = parseString()
require(keys.add(key)) { "duplicate JSON object key" }
skipWhitespace()
expect(':')
skipWhitespace()
when (key) {
"ok" -> ok = parseBoolean()
"id" -> id = parseString()
else -> skipValue(depth = 1)
}
skipWhitespace()
when {
consume('}') -> break
consume(',') -> {
skipWhitespace()
require(peek() != '}') { "trailing JSON object comma" }
}
else -> error("expected JSON object separator")
}
}
}
skipWhitespace()
require(index == source.length) { "unexpected data after JSON object" }
return ok == true && id == expectedId
}
private fun skipValue(depth: Int) {
require(depth <= MaxDiagnosticsAcknowledgementDepth) { "JSON nesting is too deep" }
when (peek()) {
'"' -> parseString()
'{' -> skipObject(depth)
'[' -> skipArray(depth)
't' -> expectLiteral("true")
'f' -> expectLiteral("false")
'n' -> expectLiteral("null")
'-' -> skipNumber()
in '0'..'9' -> skipNumber()
else -> error("invalid JSON value")
}
}
private fun skipObject(depth: Int) {
expect('{')
skipWhitespace()
if (consume('}')) return
while (true) {
parseString()
skipWhitespace()
expect(':')
skipWhitespace()
skipValue(depth + 1)
skipWhitespace()
when {
consume('}') -> return
consume(',') -> {
skipWhitespace()
require(peek() != '}') { "trailing JSON object comma" }
}
else -> error("expected JSON object separator")
}
}
}
private fun skipArray(depth: Int) {
expect('[')
skipWhitespace()
if (consume(']')) return
while (true) {
skipValue(depth + 1)
skipWhitespace()
when {
consume(']') -> return
consume(',') -> {
skipWhitespace()
require(peek() != ']') { "trailing JSON array comma" }
}
else -> error("expected JSON array separator")
}
}
}
private fun parseBoolean(): Boolean = when {
source.startsWith("true", index) -> {
index += 4
true
}
source.startsWith("false", index) -> {
index += 5
false
}
else -> error("expected JSON boolean")
}
private fun parseString(): String {
expect('"')
val value = StringBuilder()
while (index < source.length) {
val char = source[index++]
when {
char == '"' -> return value.toString()
char == '\\' -> value.append(parseEscape())
char.code < 0x20 -> error("unescaped JSON control character")
else -> value.append(char)
}
}
error("unterminated JSON string")
}
private fun parseEscape(): Char {
require(index < source.length) { "unterminated JSON escape" }
return when (val escaped = source[index++]) {
'"', '\\', '/' -> escaped
'b' -> '\b'
'f' -> '\u000c'
'n' -> '\n'
'r' -> '\r'
't' -> '\t'
'u' -> parseUnicodeEscape()
else -> error("invalid JSON escape")
}
}
private fun parseUnicodeEscape(): Char {
require(index + 4 <= source.length) { "incomplete JSON Unicode escape" }
var value = 0
repeat(4) {
value = value * 16 + source[index++].digitToIntOrNull(16).let { digit ->
requireNotNull(digit) { "invalid JSON Unicode escape" }
}
}
return value.toChar()
}
private fun skipNumber() {
consume('-')
when (peek()) {
'0' -> {
index += 1
require(peek() !in '0'..'9') { "leading zero in JSON number" }
}
in '1'..'9' -> while (peek() in '0'..'9') index += 1
else -> error("invalid JSON number")
}
if (consume('.')) {
require(peek() in '0'..'9') { "invalid JSON fraction" }
while (peek() in '0'..'9') index += 1
}
if (peek() == 'e' || peek() == 'E') {
index += 1
if (peek() == '+' || peek() == '-') index += 1
require(peek() in '0'..'9') { "invalid JSON exponent" }
while (peek() in '0'..'9') index += 1
}
}
private fun expectLiteral(literal: String) {
require(source.startsWith(literal, index)) { "invalid JSON literal" }
index += literal.length
}
private fun skipWhitespace() {
while (peek() == ' ' || peek() == '\t' || peek() == '\r' || peek() == '\n') index += 1
}
private fun expect(expected: Char) {
require(consume(expected)) { "expected '$expected'" }
}
private fun consume(expected: Char): Boolean {
if (peek() != expected) return false
index += 1
return true
}
private fun peek(): Char? = source.getOrNull(index)
}

View File

@@ -118,31 +118,36 @@ fun createDiagnosticsTransport(
appVersion: String,
platform: String,
installIdProvider: suspend () -> String,
): DiagnosticsTransport = buildDiagnosticsTransport(
endpoint = DiagnosticsBuildConfig.ENDPOINT,
ingestKey = DiagnosticsBuildConfig.INGEST_KEY,
appVersion = appVersion,
platform = platform,
installIdProvider = installIdProvider,
)
internal fun buildDiagnosticsTransport(
endpoint: String,
ingestKey: String,
appVersion: String,
platform: String,
installIdProvider: suspend () -> String,
): DiagnosticsTransport {
val endpoint = DiagnosticsBuildConfig.ENDPOINT.trim()
val ingestKey = DiagnosticsBuildConfig.INGEST_KEY.trim()
if (endpoint.isEmpty() && ingestKey.isEmpty()) return NoOpDiagnosticsTransport()
check(endpoint.isNotEmpty() && ingestKey.isNotEmpty()) {
val normalizedEndpoint = endpoint.trim()
val normalizedIngestKey = ingestKey.trim()
if (normalizedEndpoint.isEmpty() && normalizedIngestKey.isEmpty()) return NoOpDiagnosticsTransport()
check(normalizedEndpoint.isNotEmpty() && normalizedIngestKey.isNotEmpty()) {
"diagnostics endpoint and ingest key must be configured together"
}
return HttpDiagnosticsTransport(
baseUrl = endpoint,
ingestKey = ingestKey,
baseUrl = normalizedEndpoint,
ingestKey = normalizedIngestKey,
appVersion = appVersion,
platform = platform,
installIdProvider = installIdProvider,
)
}
private fun String.isSuccessfulDiagnosticsAcknowledgement(expectedId: String): Boolean {
val json = trim()
if (!SuccessfulAcknowledgement.matches(json)) return false
if (AcknowledgementOk.findAll(json).count() != 1) return false
val ids = AcknowledgementId.findAll(json).toList()
return ids.size == 1 &&
ids.single().groupValues[1] == "\"${DiagnosticsJson.escape(expectedId.lowercase())}\""
}
private fun String.isAllowedDiagnosticsEndpoint(): Boolean {
if (any(Char::isWhitespace) || '?' in this || '#' in this) return false
val schemeSeparator = indexOf("://")
@@ -180,13 +185,3 @@ private fun String.isLoopbackHost(): Boolean {
octets.first() == "127" &&
octets.all { it.toIntOrNull() in 0..255 }
}
private const val JsonStringPattern =
""""(?:[^"\\\u0000-\u001f]|\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4}))*""""
private const val JsonNumberPattern =
"""-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?"""
private val SuccessfulAcknowledgement = Regex(
"""^\s*\{\s*"ok"\s*:\s*true(?:\s*,\s*$JsonStringPattern\s*:\s*(?:$JsonStringPattern|$JsonNumberPattern|true|false|null))*\s*}\s*$""",
)
private val AcknowledgementOk = Regex(""""ok"\s*:""")
private val AcknowledgementId = Regex(""""id"\s*:\s*($JsonStringPattern)""")

View File

@@ -31,14 +31,6 @@ import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class DiagnosticsTest {
@Test
fun diagnosticsBuildConfigDefaultsToIncludedWithEmptyEndpoint() {
// Production default is true; builds can override with -Pvnidrop.diagnostics.included=false.
assertTrue(DiagnosticsBuildConfig.INCLUDED)
// Empty endpoint keeps shipping safe (NoOp transport) until Cloudflare is configured.
assertEquals("", DiagnosticsBuildConfig.ENDPOINT)
}
@Test
fun diagnosticsJsonEscapesAndShapesPayloads() {
val eventsJson = DiagnosticsJson.eventsBody(
@@ -162,6 +154,22 @@ class DiagnosticsTest {
assertEquals("https://diag.example/v1/bugs", calls[2].first)
}
@Test
fun httpTransportParsesEscapedAcknowledgementWithNestedUnknownFields() = runTest {
val transport = HttpDiagnosticsTransport(
baseUrl = "https://diag.example",
ingestKey = "secret",
post = { _, _, _ ->
PlatformHttpResponse(
202,
"""{"\u006f\u006b":true,"id":"batch-\u0031","metadata":{"stored":1,"flags":[true,null]}}""",
)
},
)
assertTrue(transport.sendEvents(TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1L)))).isSuccess)
}
@Test
fun httpTransportFailsOnHttpError() = runTest {
val transport = HttpDiagnosticsTransport(
@@ -212,6 +220,31 @@ class DiagnosticsTest {
}
}
@Test
fun httpTransportRejectsAmbiguousOrMalformedJsonAcknowledgement() = runTest {
val responses = ArrayDeque(
listOf(
PlatformHttpResponse(202, """{"ok":true,"\u006f\u006b":true,"id":"batch-0"}"""),
PlatformHttpResponse(202, """{"ok":true,"id":true}"""),
PlatformHttpResponse(202, """{"ok":true,"id":"batch-2","stored":01}"""),
PlatformHttpResponse(202, """{"ok":true,"id":"batch-3",}"""),
PlatformHttpResponse(202, """{"ok":true,"id":"batch-4"} trailing"""),
),
)
val transport = HttpDiagnosticsTransport(
baseUrl = "https://diag.example",
ingestKey = "secret",
post = { _, _, _ -> responses.removeFirst() },
)
repeat(5) { index ->
val result = transport.sendEvents(
TelemetryBatch("batch-$index", listOf(TelemetryEvent("x", 1L))),
)
assertIs<DiagnosticsProtocolException>(result.exceptionOrNull())
}
}
@Test
fun httpTransportRequiresHttpsExceptForLoopbackDevelopment() {
assertFailsWith<IllegalArgumentException> {
@@ -229,9 +262,10 @@ class DiagnosticsTest {
}
@Test
fun createTransportIsNoOpWhenEndpointBlank() {
// With default generated config endpoint is empty.
val transport = createDiagnosticsTransport(
fun createTransportIsNoOpForExplicitEmptyConfiguration() {
val transport = buildDiagnosticsTransport(
endpoint = "",
ingestKey = "",
appVersion = "1.0",
platform = "Test",
installIdProvider = { "id" },