Summary
NapAuthController.resolveAuthorization() falls back to reading the NIP-98 proof from a proof field in the JSON request body when the Authorization header is absent. This parses the raw body with a second, independent ObjectMapper.readValue() call, outside the validated path, and accepts a credential from a location the signed payload hash cannot cover consistently.
Severity: Medium — not obviously exploitable today, but it is an undocumented second entry point into the authentication path, and it weakens a property the codebase is otherwise careful about.
private String resolveAuthorization(HttpServletRequest request, byte[] rawBody) {
String header = request.getHeader("Authorization");
if (header != null && !header.isBlank()) return header;
try {
Map<String, Object> body = objectMapper.readValue(rawBody, Map.class);
Object proof = body.get("proof");
if (proof instanceof String proofValue && !proofValue.isBlank()) return proofValue;
} catch (Exception ignored) { }
return null;
}
Why it matters
1. It is not in the protocol surface. The README documents POST /api/v1/auth/complete as "Verify the NIP-98 proof" with the proof in Authorization. Nip98Validator requires Authorization: Nostr <base64>, and the TypeScript implementation has no equivalent fallback — verifyCompletion() reads input.authorization only. So this is a JVM-only accepted credential location that no specification, test vector, or interop test describes.
2. Self-referential hash coverage. NIP-98's payload tag commits to sha256(rawBody). When the proof travels in the header, the body it hashes is the body. When the proof travels inside the body, the proof is part of what it must hash — the client has to construct a body containing a proof whose payload tag equals the hash of that same body. That is satisfiable only by excluding the proof field before hashing, which nothing here specifies or enforces. Whatever a client does to make this work is an unwritten convention on the most security-critical hash in the protocol.
3. Second parse of attacker-controlled bytes. DefaultNapServer.parseAuthCompleteRequest() already parses rawBody under a strict shape check. This adds an independent readValue(..., Map.class) on the same bytes with different semantics and a blanket catch (Exception ignored). Two parsers over one attacker-controlled buffer is a parser-differential shape — the class of bug behind HTTP request smuggling — and the swallowed exception means a body that parses differently here than there produces no signal at all.
4. Credentials in bodies get logged. The /auth/refresh handler documents exactly this concern:
The token is presented as Authorization: Bearer <token>, not in the body: it is a credential, and a body would be logged by anything that logs request payloads.
That reasoning applies identically to the NIP-98 proof, and this fallback contradicts it within the same controller.
Fix
Remove the fallback:
private static String resolveAuthorization(HttpServletRequest request) {
return request.getHeader("Authorization");
}
Nip98Validator already returns NAP_COMPLETE_MISSING_AUTH_HEADER for a null header, so the failure stays uniform, padded, and audited.
If a body-carried proof is genuinely needed for some client — the git history would say whether it ever was — it should be specified rather than inferred: declared in the RFC, given a defined hashing rule that states which fields the payload tag covers, mirrored in TypeScript, and pinned by a nap-it interop test. An undocumented fallback that only one implementation honours is the worst of both.
Regression test
@Test
void completeRejectsAProofCarriedInTheBody() {
var proof = buildValidProof(challenge); // would succeed in the header
var body = Map.of("challenge_id", challenge.challengeId(), "proof", proof);
var response = post("/api/v1/auth/complete", body); // no Authorization header
assertThat(response.getStatus()).isEqualTo(401);
}
Asserting that a valid proof in the body is refused is the point — a test using an invalid proof would pass whether or not the fallback exists.
Summary
NapAuthController.resolveAuthorization()falls back to reading the NIP-98 proof from aprooffield in the JSON request body when theAuthorizationheader is absent. This parses the raw body with a second, independentObjectMapper.readValue()call, outside the validated path, and accepts a credential from a location the signedpayloadhash cannot cover consistently.Severity: Medium — not obviously exploitable today, but it is an undocumented second entry point into the authentication path, and it weakens a property the codebase is otherwise careful about.
Why it matters
1. It is not in the protocol surface. The README documents
POST /api/v1/auth/completeas "Verify the NIP-98 proof" with the proof inAuthorization.Nip98ValidatorrequiresAuthorization: Nostr <base64>, and the TypeScript implementation has no equivalent fallback —verifyCompletion()readsinput.authorizationonly. So this is a JVM-only accepted credential location that no specification, test vector, or interop test describes.2. Self-referential hash coverage. NIP-98's
payloadtag commits tosha256(rawBody). When the proof travels in the header, the body it hashes is the body. When the proof travels inside the body, the proof is part of what it must hash — the client has to construct a body containing a proof whose payload tag equals the hash of that same body. That is satisfiable only by excluding theprooffield before hashing, which nothing here specifies or enforces. Whatever a client does to make this work is an unwritten convention on the most security-critical hash in the protocol.3. Second parse of attacker-controlled bytes.
DefaultNapServer.parseAuthCompleteRequest()already parsesrawBodyunder a strict shape check. This adds an independentreadValue(..., Map.class)on the same bytes with different semantics and a blanketcatch (Exception ignored). Two parsers over one attacker-controlled buffer is a parser-differential shape — the class of bug behind HTTP request smuggling — and the swallowed exception means a body that parses differently here than there produces no signal at all.4. Credentials in bodies get logged. The
/auth/refreshhandler documents exactly this concern:That reasoning applies identically to the NIP-98 proof, and this fallback contradicts it within the same controller.
Fix
Remove the fallback:
Nip98Validatoralready returnsNAP_COMPLETE_MISSING_AUTH_HEADERfor a null header, so the failure stays uniform, padded, and audited.If a body-carried proof is genuinely needed for some client — the git history would say whether it ever was — it should be specified rather than inferred: declared in the RFC, given a defined hashing rule that states which fields the
payloadtag covers, mirrored in TypeScript, and pinned by anap-itinterop test. An undocumented fallback that only one implementation honours is the worst of both.Regression test
Asserting that a valid proof in the body is refused is the point — a test using an invalid proof would pass whether or not the fallback exists.