fix: stop a malformed API key reaching a log line

HttpRequest.Builder.header() quotes the offending header value back in
its IllegalArgumentException. Fuzzed on temurin-25: all 32 control
characters it rejects echo the value, so a key carrying any of them ends
up in whatever log catches the throw. A key file with a comment on line
two survives trim() and is enough to trigger it.

Ai.cleanKey now takes the first non-blank line and drops control
characters, so a malformed key never forms. HttpFetcher.checkBearer
rejects one anyway before the request is built, with a message naming
only the position, so no future caller has to remember to redact. Its
reject set is a strict superset of the JDK's.

Also renames MiniMax.Msg to MiniMax.Turn: the package already has a
top-level Msg, the chat-formatting helper, which the record shadowed
inside MiniMax.java.

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:05:22 +00:00
parent 6e0167cff1
commit 88fe9c9695
6 changed files with 224 additions and 12 deletions
@@ -66,16 +66,51 @@ final class Ai {
return apiKey() != null;
}
/**
* The first non-blank line of a key file or variable, with any control
* character removed. Null if there is nothing usable.
*
* <p>{@code trim()} alone leaves an <em>interior</em> newline — a key file
* with the key on line 1 and a comment on line 2 survives it. Such a key
* cannot go in an HTTP header, and the JDK's rejection of it quotes the
* whole header value, key included, into the exception message, which then
* reaches the server log. Cleaning at the source means that never happens;
* {@link HttpFetcher} checks again as a backstop.
*
* <p>Takes the first line rather than deleting the newline and joining, so
* a trailing comment line cannot be silently welded onto the key to make a
* different, wrong one — that would turn a readable failure into a puzzling
* authentication error.
*/
static String cleanKey(String raw) {
if (raw == null) {
return null;
}
for (String line : raw.split("\\R")) {
StringBuilder out = new StringBuilder(line.length());
for (int i = 0; i < line.length(); i++) {
char c = line.charAt(i);
if (c >= 0x20 && c != 0x7F) {
out.append(c);
}
}
String key = out.toString().trim();
if (!key.isEmpty()) {
return key;
}
}
return null;
}
private String apiKey() {
String fromEnv = System.getenv(KEY_ENV);
if (fromEnv != null && !fromEnv.isBlank()) {
return fromEnv.trim();
return cleanKey(fromEnv);
}
Path file = plugin.getDataFolder().toPath().resolve(KEY_FILE);
try {
if (Files.isReadable(file)) {
String key = Files.readString(file, StandardCharsets.UTF_8).trim();
return key.isEmpty() ? null : key;
return cleanKey(Files.readString(file, StandardCharsets.UTF_8));
}
} catch (IOException e) {
plugin.getLogger().warning("Não consegui ler " + KEY_FILE + ": " + e.getMessage());
@@ -36,9 +36,38 @@ final class HttpFetcher implements Fetcher {
return body(http.send(request, HttpResponse.BodyHandlers.ofString()));
}
/**
* Rejects a bearer token that cannot go in a header, before it reaches one.
*
* <p>{@code HttpRequest.Builder.header()} validates the value itself, but
* its {@code IllegalArgumentException} quotes the offending value back —
* measured on temurin-25: {@code invalid header value: "Bearer sk-…"}. That
* throw happens before any I/O, so it reaches callers as an ordinary call
* failure and lands the credential in whatever log catches it. A key with a
* stray second line is enough to trigger it.
*
* <p>Checking here rather than at the call site means no future caller has
* to remember to redact. The message deliberately names only the position
* of the offending character, never any part of the key.
*/
static void checkBearer(String bearer) throws IOException {
if (bearer == null || bearer.isEmpty()) {
throw new IOException("Chave de API vazia.");
}
for (int i = 0; i < bearer.length(); i++) {
char c = bearer.charAt(i);
if (c < 0x20 || c == 0x7F) {
throw new IOException("Chave de API inválida: caractere de controle na posição "
+ i + " (de " + bearer.length() + "). "
+ "Verifique se o arquivo da chave tem uma única linha, sem quebra de linha.");
}
}
}
@Override
public String postJson(String url, String json, String bearer)
throws IOException, InterruptedException {
checkBearer(bearer);
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(timeoutSeconds))
.header("Content-Type", "application/json")
@@ -59,8 +59,15 @@ final class MiniMax {
*/
private static final double TERM_TEMPERATURE = 0.0;
/** One message in the request. */
record Msg(String role, String content) {
/**
* One message in the request.
*
* <p>Named {@code Turn} and not {@code Msg} because the package already has
* a top-level {@link Msg}, the chat-formatting helper. A nested record of
* that name would shadow it inside this file, so a later {@code Msg.error}
* here would resolve to the record instead.
*/
record Turn(String role, String content) {
}
private final Fetcher fetcher;
@@ -103,8 +110,8 @@ final class MiniMax {
*/
String searchTerm(String key, String model, String question) {
JsonObject body = base(model, List.of(
new Msg("system", "Você escolhe qual artigo da Minecraft Wiki consultar."),
new Msg("user", question)), TERM_TOKENS, TERM_TEMPERATURE);
new Turn("system", "Você escolhe qual artigo da Minecraft Wiki consultar."),
new Turn("user", question)), TERM_TOKENS, TERM_TEMPERATURE);
body.add("tools", JsonParser.parseString(TOOLS).getAsJsonArray());
body.add("tool_choice", JsonParser.parseString(TOOL_CHOICE).getAsJsonObject());
@@ -146,7 +153,7 @@ final class MiniMax {
* spends the whole allowance thinking and returns nothing at all. Measured
* twice at 400 tokens, which is why answers are given 1200.
*/
String answer(String key, String model, List<Msg> messages, int maxTokens, double temperature) {
String answer(String key, String model, List<Turn> messages, int maxTokens, double temperature) {
JsonObject message = message(post(key, base(model, messages, maxTokens, temperature)));
if (message == null) {
return null;
@@ -160,9 +167,9 @@ final class MiniMax {
return content.getAsString();
}
private JsonObject base(String model, List<Msg> messages, int maxTokens, double temperature) {
private JsonObject base(String model, List<Turn> messages, int maxTokens, double temperature) {
JsonArray array = new JsonArray();
for (Msg msg : messages) {
for (Turn msg : messages) {
JsonObject object = new JsonObject();
object.addProperty("role", msg.role());
object.addProperty("content", msg.content());