mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
fix(diagnostics): configure production ingestion safely
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)""")
|
||||
|
||||
Reference in New Issue
Block a user