mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
fix(diagnostics): configure production ingestion safely
This commit is contained in:
@@ -2,6 +2,8 @@
|
|||||||
"$schema": "./node_modules/wrangler/config-schema.json",
|
"$schema": "./node_modules/wrangler/config-schema.json",
|
||||||
"name": "vnidrop-diagnostics",
|
"name": "vnidrop-diagnostics",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
"workers_dev": true,
|
||||||
|
"preview_urls": false,
|
||||||
"compatibility_date": "2026-07-14",
|
"compatibility_date": "2026-07-14",
|
||||||
"compatibility_flags": ["nodejs_compat"],
|
"compatibility_flags": ["nodejs_compat"],
|
||||||
"upload_source_maps": true,
|
"upload_source_maps": true,
|
||||||
@@ -16,8 +18,7 @@
|
|||||||
{
|
{
|
||||||
"binding": "DB",
|
"binding": "DB",
|
||||||
"database_name": "vnidrop-diagnostics",
|
"database_name": "vnidrop-diagnostics",
|
||||||
// Replace this placeholder with the ID returned by `wrangler d1 create`.
|
"database_id": "b9e18b17-d9fe-477b-8ace-2b1439d1694e",
|
||||||
"database_id": "00000000-0000-0000-0000-000000000000",
|
|
||||||
"migrations_dir": "migrations",
|
"migrations_dir": "migrations",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
{
|
{
|
||||||
"binding": "BLOBS",
|
"binding": "BLOBS",
|
||||||
"bucket_name": "vnidrop-diagnostics",
|
"bucket_name": "vnidrop-diagnostics",
|
||||||
|
"jurisdiction": "eu",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"ratelimits": [
|
"ratelimits": [
|
||||||
|
|||||||
@@ -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,
|
appVersion: String,
|
||||||
platform: String,
|
platform: String,
|
||||||
installIdProvider: suspend () -> 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 {
|
): DiagnosticsTransport {
|
||||||
val endpoint = DiagnosticsBuildConfig.ENDPOINT.trim()
|
val normalizedEndpoint = endpoint.trim()
|
||||||
val ingestKey = DiagnosticsBuildConfig.INGEST_KEY.trim()
|
val normalizedIngestKey = ingestKey.trim()
|
||||||
if (endpoint.isEmpty() && ingestKey.isEmpty()) return NoOpDiagnosticsTransport()
|
if (normalizedEndpoint.isEmpty() && normalizedIngestKey.isEmpty()) return NoOpDiagnosticsTransport()
|
||||||
check(endpoint.isNotEmpty() && ingestKey.isNotEmpty()) {
|
check(normalizedEndpoint.isNotEmpty() && normalizedIngestKey.isNotEmpty()) {
|
||||||
"diagnostics endpoint and ingest key must be configured together"
|
"diagnostics endpoint and ingest key must be configured together"
|
||||||
}
|
}
|
||||||
return HttpDiagnosticsTransport(
|
return HttpDiagnosticsTransport(
|
||||||
baseUrl = endpoint,
|
baseUrl = normalizedEndpoint,
|
||||||
ingestKey = ingestKey,
|
ingestKey = normalizedIngestKey,
|
||||||
appVersion = appVersion,
|
appVersion = appVersion,
|
||||||
platform = platform,
|
platform = platform,
|
||||||
installIdProvider = installIdProvider,
|
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 {
|
private fun String.isAllowedDiagnosticsEndpoint(): Boolean {
|
||||||
if (any(Char::isWhitespace) || '?' in this || '#' in this) return false
|
if (any(Char::isWhitespace) || '?' in this || '#' in this) return false
|
||||||
val schemeSeparator = indexOf("://")
|
val schemeSeparator = indexOf("://")
|
||||||
@@ -180,13 +185,3 @@ private fun String.isLoopbackHost(): Boolean {
|
|||||||
octets.first() == "127" &&
|
octets.first() == "127" &&
|
||||||
octets.all { it.toIntOrNull() in 0..255 }
|
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)""")
|
|
||||||
|
|||||||
@@ -31,14 +31,6 @@ import kotlin.test.assertTrue
|
|||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class DiagnosticsTest {
|
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
|
@Test
|
||||||
fun diagnosticsJsonEscapesAndShapesPayloads() {
|
fun diagnosticsJsonEscapesAndShapesPayloads() {
|
||||||
val eventsJson = DiagnosticsJson.eventsBody(
|
val eventsJson = DiagnosticsJson.eventsBody(
|
||||||
@@ -162,6 +154,22 @@ class DiagnosticsTest {
|
|||||||
assertEquals("https://diag.example/v1/bugs", calls[2].first)
|
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
|
@Test
|
||||||
fun httpTransportFailsOnHttpError() = runTest {
|
fun httpTransportFailsOnHttpError() = runTest {
|
||||||
val transport = HttpDiagnosticsTransport(
|
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
|
@Test
|
||||||
fun httpTransportRequiresHttpsExceptForLoopbackDevelopment() {
|
fun httpTransportRequiresHttpsExceptForLoopbackDevelopment() {
|
||||||
assertFailsWith<IllegalArgumentException> {
|
assertFailsWith<IllegalArgumentException> {
|
||||||
@@ -229,9 +262,10 @@ class DiagnosticsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun createTransportIsNoOpWhenEndpointBlank() {
|
fun createTransportIsNoOpForExplicitEmptyConfiguration() {
|
||||||
// With default generated config endpoint is empty.
|
val transport = buildDiagnosticsTransport(
|
||||||
val transport = createDiagnosticsTransport(
|
endpoint = "",
|
||||||
|
ingestKey = "",
|
||||||
appVersion = "1.0",
|
appVersion = "1.0",
|
||||||
platform = "Test",
|
platform = "Test",
|
||||||
installIdProvider = { "id" },
|
installIdProvider = { "id" },
|
||||||
|
|||||||
Reference in New Issue
Block a user