fix: restrict API keys to printable ASCII, and harden response parsing

Fuzzing all 65,536 char values, not the 256 of the previous pass, shows
the JDK rejects 65,312 of them from a header value and every single one
echoes the value back. The previous deny-list covered 33. A key file
saved by Notepad or PowerShell Out-File carries a U+FEFF byte order
mark, which passed both filters and reached the quoting validator.

Ai.cleanKey and HttpFetcher.checkBearer now allow printable ASCII only,
an allow-list of the 94 characters a bearer token is made of, which
cannot drift out of what the JDK accepts. Demonstrated invariant:
cleanKey output ⊆ checkBearer accepts ⊆ JDK accepts, 0 violations.

Ai.call gains the same check and its failure log is now redacted; that
was the one path where the proof-of-concept leak surfaced.

MiniMax.message and answer now check JSON types before assuming them.
Gson throws unchecked on JSON that parses but has the wrong shape, and
both run outside post()'s try, so {"choices":["str"]} escaped to an
async Bukkit worker as a stack trace instead of the promised null.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jSvSTrG4qsniLSr6TpC6
This commit is contained in:
marcos
2026-08-05 16:18:32 +00:00
parent 88fe9c9695
commit c5d8f44bf2
6 changed files with 187 additions and 21 deletions
@@ -159,7 +159,11 @@ final class MiniMax {
return null;
}
JsonElement content = message.get("content");
if (content == null || content.isJsonNull() || content.getAsString().isBlank()) {
// isJsonPrimitive before getAsString: on an object or array that call
// throws UnsupportedOperationException, and this runs outside post()'s
// try. Some OpenAI-compatible servers return content as an array of
// parts rather than a string.
if (content == null || !content.isJsonPrimitive() || content.getAsString().isBlank()) {
warn.accept("IA: resposta vazia com max_tokens=" + maxTokens
+ " (o raciocínio do modelo pode ter consumido o orçamento).");
return null;
@@ -200,7 +204,10 @@ final class MiniMax {
warn.accept("IA: chamada interrompida.");
return null;
} catch (Exception e) {
redacted(key, "IA: falha na chamada: " + e);
// The host is named because MiniMax's regional endpoints reject
// each other's keys: without it a wrong-host misconfiguration and
// an expired key produce the same log line.
redacted(key, "IA: falha na chamada a " + url + ": " + e);
return null;
}
}
@@ -219,7 +226,16 @@ final class MiniMax {
warn.accept(key == null || key.isEmpty() ? message : message.replace(key, "***"));
}
/** The first choice's message, or null if the response reported a failure. */
/**
* The first choice's message, or null if the response reported a failure.
*
* <p>Every step checks the <em>type</em> it is about to assume, not just
* the presence of the key. This is called outside {@link #post}'s try, and
* Gson's {@code getAsJsonObject}/{@code getAsInt} throw unchecked on JSON
* that parses but has the wrong shape — {@code {"base_resp":"texto"}} or
* {@code {"choices":["texto"]}} — which would escape to the async worker as
* a bare stack trace instead of the null this method promises.
*/
private JsonObject message(JsonObject root) {
if (root == null) {
return null;
@@ -227,9 +243,12 @@ final class MiniMax {
// HTTP 200 does not mean success here: MiniMax reports application
// errors — bad key, no balance, rate limit — with a 200 and a non-zero
// status_code, so checking the HTTP status alone misses all of them.
if (root.has("base_resp")) {
JsonObject base = root.getAsJsonObject("base_resp");
if (base.has("status_code") && base.get("status_code").getAsInt() != 0) {
JsonElement baseResp = root.get("base_resp");
if (baseResp != null && baseResp.isJsonObject()) {
JsonObject base = baseResp.getAsJsonObject();
JsonElement status = base.get("status_code");
if (status != null && status.isJsonPrimitive() && status.getAsJsonPrimitive().isNumber()
&& status.getAsInt() != 0) {
warn.accept("IA: recusada, base_resp " + AiText.forLog(base.toString()));
return null;
}
@@ -239,7 +258,12 @@ final class MiniMax {
warn.accept("IA: resposta sem \"choices\": " + AiText.forLog(root.toString()));
return null;
}
JsonObject first = choices.getAsJsonArray().get(0).getAsJsonObject();
return first.has("message") ? first.getAsJsonObject("message") : null;
JsonElement first = choices.getAsJsonArray().get(0);
JsonElement message = first.isJsonObject() ? first.getAsJsonObject().get("message") : null;
if (message == null || !message.isJsonObject()) {
warn.accept("IA: choices com formato inesperado: " + AiText.forLog(root.toString()));
return null;
}
return message.getAsJsonObject();
}
}