commit 35f111bdbfb0a80ab398139dc5e4ff66679a4198
parent 4cce9f30d17521bdbf2207ad526c9624f164cd2a
Author: Christian Grothoff <christian@grothoff.org>
Date: Thu, 6 Aug 2026 23:09:34 +0200
more stricter standards-compliance of various responses
Diffstat:
12 files changed, 360 insertions(+), 48 deletions(-)
diff --git a/src/challenger/challenger-httpd.c b/src/challenger/challenger-httpd.c
@@ -284,6 +284,14 @@ url_handler (void *cls,
hc->rh = rh;
break;
}
+ if ( (0 == strcasecmp (method,
+ MHD_HTTP_METHOD_HEAD)) &&
+ (0 == strcasecmp (rh->method,
+ MHD_HTTP_METHOD_GET)) )
+ {
+ hc->rh = rh;
+ break;
+ }
}
}
if (NULL == hc->rh)
@@ -297,7 +305,8 @@ url_handler (void *cls,
GNUNET_break_op (0);
/* Build Allow header: OPTIONS is always supported (handled above),
- plus every method registered for this URL. */
+ plus every method registered for this URL, plus HEAD wherever
+ GET is registered */
for (unsigned int j = 0; NULL != handlers[j].url; j++)
{
const struct CH_RequestHandler *rh2 = &handlers[j];
@@ -313,6 +322,18 @@ url_handler (void *cls,
memcpy (&allow[aoff], rh2->method, strlen (rh2->method));
aoff += strlen (rh2->method);
allow[aoff] = '\0';
+ if (0 != strcasecmp (rh2->method,
+ MHD_HTTP_METHOD_GET))
+ continue;
+ GNUNET_assert (aoff + strlen (MHD_HTTP_METHOD_HEAD) + 3
+ < sizeof (allow));
+ memcpy (&allow[aoff], ", ", 2);
+ aoff += 2;
+ memcpy (&allow[aoff],
+ MHD_HTTP_METHOD_HEAD,
+ strlen (MHD_HTTP_METHOD_HEAD));
+ aoff += strlen (MHD_HTTP_METHOD_HEAD);
+ allow[aoff] = '\0';
}
resp = MHD_create_response_from_buffer (0,
diff --git a/src/challenger/challenger-httpd_authorize.c b/src/challenger/challenger-httpd_authorize.c
@@ -209,6 +209,43 @@ CH_handler_authorize (struct CH_HandlerContext *hc,
= MHD_lookup_connection_value (hc->connection,
MHD_GET_ARGUMENT_KIND,
"scope");
+ /* Everything below is stored verbatim in text columns. Postgres rejects
+ invalid UTF-8 with SQLSTATE 22021, which we would surface as a 500;
+ catch it here so a bad request is reported as one. */
+ {
+ const struct
+ {
+ const char *name;
+ const char *value;
+ } texts[] = {
+ { "redirect_uri", redirect_uri },
+ { "state", state },
+ { "scope", scope },
+ { "code_challenge", code_challenge },
+ { NULL, NULL }
+ };
+
+ for (unsigned int i = 0; NULL != texts[i].name; i++)
+ {
+ json_t *probe;
+
+ if (NULL == texts[i].value)
+ continue;
+ /* json_string() returns NULL for anything that is not valid UTF-8;
+ this is the same check /challenge applies to its form fields. */
+ probe = json_string (texts[i].value);
+ if (NULL == probe)
+ {
+ GNUNET_break_op (0);
+ return reply_error (
+ hc,
+ MHD_HTTP_BAD_REQUEST,
+ TALER_EC_GENERIC_PARAMETER_MALFORMED,
+ texts[i].name);
+ }
+ json_decref (probe);
+ }
+ }
{
json_t *last_address = NULL;
uint32_t address_attempts_left;
diff --git a/src/challenger/challenger-httpd_challenge.c b/src/challenger/challenger-httpd_challenge.c
@@ -808,9 +808,9 @@ CH_handler_challenge (struct CH_HandlerContext *hc,
GNUNET_break_op (0);
return TALER_MHD_reply_with_error (
hc->connection,
- MHD_HTTP_BAD_REQUEST,
+ MHD_HTTP_UNSUPPORTED_MEDIA_TYPE,
TALER_EC_GENERIC_PARAMETER_MALFORMED,
- "Content-Type");
+ MHD_HTTP_HEADER_CONTENT_TYPE);
}
}
GNUNET_log (GNUNET_ERROR_TYPE_INFO,
@@ -909,6 +909,19 @@ CH_handler_challenge (struct CH_HandlerContext *hc,
TALER_EC_GENERIC_PARAMETER_MALFORMED,
bc->bad_key);
}
+ if (! json_is_object (bc->address))
+ {
+ /* The address is echoed back with GNUNET_JSON_pack_object_incref() and
+ (if a message template is configured) expanded by send_tan(), both of
+ which assert on anything that is not an object; refuse it here
+ instead. Only reachable through an "application/json" upload, as the
+ form encodings always build an object. */
+ GNUNET_break_op (0);
+ return reply_error (bc,
+ MHD_HTTP_BAD_REQUEST,
+ TALER_EC_GENERIC_PARAMETER_MALFORMED,
+ "address");
+ }
#if DEBUG
{
char *address;
diff --git a/src/challenger/challenger-httpd_common.c b/src/challenger/challenger-httpd_common.c
@@ -168,6 +168,29 @@ CH_code_to_nonce (const char *code,
}
+/**
+ * Return a copy of @a hint that is safe to embed in the quoted-string of
+ * an HTTP header: RFC 9110 quoted-strings may not contain a bare '"' or
+ * '\\', and error code hints are free-form text that sometimes does.
+ * Rather than emitting quoted-pairs (which sloppy clients mis-parse), the
+ * offending characters are substituted.
+ *
+ * @param hint text to sanitize
+ * @return newly allocated sanitized string
+ */
+static char *
+quote_safe (const char *hint)
+{
+ char *out = GNUNET_strdup (hint);
+
+ for (char *p = out; '\0' != *p; p++)
+ if ( ('"' == *p) ||
+ ('\\' == *p) )
+ *p = '\'';
+ return out;
+}
+
+
enum MHD_Result
CH_reply_with_oauth_error (
struct MHD_Connection *connection,
@@ -182,8 +205,9 @@ CH_reply_with_oauth_error (
resp = TALER_MHD_make_json_steal (
GNUNET_JSON_PACK (
TALER_MHD_PACK_EC (ec),
- GNUNET_JSON_pack_string ("error",
- oauth_error),
+ GNUNET_JSON_pack_allow_null (
+ GNUNET_JSON_pack_string ("error",
+ oauth_error)),
GNUNET_JSON_pack_allow_null (
GNUNET_JSON_pack_string ("detail",
detail))));
@@ -191,9 +215,27 @@ CH_reply_with_oauth_error (
{
char *www_auth;
- GNUNET_asprintf (&www_auth,
- "Bearer error=\"%s\"",
- oauth_error);
+ /* RFC 6750 section 3: the challenge carries a realm, and SHOULD carry
+ an error_description; but when the request contained no
+ authentication credentials at all it must NOT carry an error code,
+ which the caller signals by passing NULL for @a oauth_error. */
+ if (NULL == oauth_error)
+ {
+ GNUNET_asprintf (&www_auth,
+ "Bearer realm=\"%s\"",
+ CH_OAUTH_REALM);
+ }
+ else
+ {
+ char *desc = quote_safe (TALER_ErrorCode_get_hint (ec));
+
+ GNUNET_asprintf (&www_auth,
+ "Bearer realm=\"%s\", error=\"%s\", error_description=\"%s\"",
+ CH_OAUTH_REALM,
+ oauth_error,
+ desc);
+ GNUNET_free (desc);
+ }
GNUNET_break (MHD_YES ==
MHD_add_response_header (
resp,
diff --git a/src/challenger/challenger-httpd_common.h b/src/challenger/challenger-httpd_common.h
@@ -24,6 +24,14 @@
#include "challenger-httpd.h"
/**
+ * Protection space announced in the "WWW-Authenticate" challenge, as per
+ * RFC 6750 section 3. Challenger serves a single protection space, so
+ * this is a constant.
+ */
+#define CH_OAUTH_REALM "challenger"
+
+
+/**
* Extract the client secret from the
* authorization header of @a connection.
*
@@ -105,9 +113,15 @@ CH_build_full_redirect_url (const struct CHALLENGER_ValidationNonceP *nonce,
* Send a OAuth 2.0 response indicating an error following
* section 5.2 of RFC 6749.
*
+ * On a 401 this also emits the "WWW-Authenticate" challenge required by
+ * RFC 6750 section 3, carrying the realm and (unless @a oauth_error is
+ * NULL) the error code and a human-readable error_description.
+ *
* @param connection the MHD connection to use
* @param ec error code uniquely identifying the error
- * @param oauth_error error as of the OAuth 2.0 protocol
+ * @param oauth_error error as of the OAuth 2.0 protocol, or NULL if the
+ * request carried no authentication credentials at all: RFC 6750
+ * section 3 requires that the challenge then omits the error code
* @param http_status HTTP status code to use
* @param detail additional optional detail about the error
* @return a MHD result code
diff --git a/src/challenger/challenger-httpd_config.c b/src/challenger/challenger-httpd_config.c
@@ -36,11 +36,16 @@
* 7: added ``build_version`` field in ``/config``
* 8: HTTP status code and error code corrections: /token no longer
* returns 409 and /info no longer returns 404; /solve tells a wrong
- * PIN from one that could not be checked at all; /challenge and /solve
- * use distinct error codes for each way of running out of attempts;
- * address restrictions have their own error codes and a restriction we
- * cannot evaluate is a 500 rather than a 400; a malformed nonce is a
- * 400 everywhere; /setup answers missing credentials with 403.
+ * PIN from one that could not be checked at all and answers every
+ * failure with the same body shape; /challenge and /solve use distinct
+ * error codes for each way of running out of attempts; address
+ * restrictions have their own error codes and a restriction we cannot
+ * evaluate is a 500 rather than a 400; a malformed nonce is a 400
+ * everywhere; /setup answers missing credentials with 403; every error
+ * code maps to exactly one HTTP status; HEAD is accepted wherever GET
+ * is; an unusable Content-Type gives 415 rather than 400; and /token
+ * and /info always carry the RFC 6749 "error" member plus a full
+ * RFC 6750 "WWW-Authenticate" challenge.
*/
diff --git a/src/challenger/challenger-httpd_info.c b/src/challenger/challenger-httpd_info.c
@@ -78,10 +78,12 @@ CH_handler_info (struct CH_HandlerContext *hc,
if (NULL == auth)
{
GNUNET_break_op (0);
+ /* RFC 6750 section 3: a challenge answering a request that carried no
+ authentication credentials must not include an error code. */
return CH_reply_with_oauth_error (
hc->connection,
MHD_HTTP_UNAUTHORIZED,
- "invalid_request",
+ NULL,
TALER_EC_GENERIC_PARAMETER_MISSING,
MHD_HTTP_HEADER_AUTHORIZATION);
}
@@ -99,7 +101,8 @@ CH_handler_info (struct CH_HandlerContext *hc,
MHD_HTTP_HEADER_AUTHORIZATION);
}
token = auth + strlen (BEARER_PREFIX);
-
+ /* RFC 7235: the scheme and the credentials are separated by 1*SP;
+ note that the single space was already in our BEARER_PREFIX */
if (GNUNET_OK !=
GNUNET_STRINGS_string_to_data (token,
strlen (token),
@@ -127,18 +130,22 @@ CH_handler_info (struct CH_HandlerContext *hc,
{
case GNUNET_DB_STATUS_HARD_ERROR:
GNUNET_break (0);
- return TALER_MHD_reply_with_error (hc->connection,
- MHD_HTTP_INTERNAL_SERVER_ERROR,
- TALER_EC_GENERIC_DB_FETCH_FAILED,
- "get_token");
+ return CH_reply_with_oauth_error (hc->connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ "server_error",
+ TALER_EC_GENERIC_DB_FETCH_FAILED,
+ "get_token");
case GNUNET_DB_STATUS_SOFT_ERROR:
if (r < MAX_RETRIES - 1)
continue;
+ /* Retries exhausted on serialization failures; unlike a hard error
+ this is worth retrying from the client side. */
GNUNET_break (0);
- return TALER_MHD_reply_with_error (hc->connection,
- MHD_HTTP_INTERNAL_SERVER_ERROR,
- TALER_EC_GENERIC_DB_FETCH_FAILED,
- "get_token");
+ return CH_reply_with_oauth_error (hc->connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ "server_error",
+ TALER_EC_GENERIC_DB_SOFT_FAILURE,
+ "get_token");
case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
return reply_invalid_token (hc->connection);
case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT:
diff --git a/src/challenger/challenger-httpd_solve.c b/src/challenger/challenger-httpd_solve.c
@@ -225,9 +225,9 @@ CH_handler_solve (struct CH_HandlerContext *hc,
GNUNET_break_op (0);
return TALER_MHD_reply_with_error (
hc->connection,
- MHD_HTTP_BAD_REQUEST,
+ MHD_HTTP_UNSUPPORTED_MEDIA_TYPE,
TALER_EC_GENERIC_PARAMETER_MALFORMED,
- "Content-Type");
+ MHD_HTTP_HEADER_CONTENT_TYPE);
}
return MHD_YES;
}
@@ -339,15 +339,15 @@ CH_handler_solve (struct CH_HandlerContext *hc,
(0 == bc->auth_attempts_left) &&
(0 == bc->pin_transmissions_left) )
{
+ /* Terminal: no address change, no retransmission and no guess
+ remains. Reported with the same body as every other /solve
+ failure (all counters are zero) so that a client only ever has
+ to parse one shape; the error code tells the cases apart. */
GNUNET_log (GNUNET_ERROR_TYPE_INFO,
"Client exhausted all chances to satisfy challenge\n");
- return TALER_MHD_reply_with_error (
- hc->connection,
- MHD_HTTP_TOO_MANY_REQUESTS,
- TALER_EC_CHALLENGER_TOO_MANY_ATTEMPTS,
- "users exhausted all possibilities of passing the check");
+ http_status = MHD_HTTP_TOO_MANY_REQUESTS;
+ ec = TALER_EC_CHALLENGER_TOO_MANY_ATTEMPTS;
}
-
/* Distinguish the three ways in which a /solve can fail. Only the
last one is actually about the PIN that was submitted; reporting
the other two as "the PIN code provided is incorrect" misleads
@@ -355,7 +355,7 @@ CH_handler_solve (struct CH_HandlerContext *hc,
tell "provide your address first" from "wait or ask for a new PIN"
from "that PIN was wrong" without parsing the human-readable
hint. */
- if (no_challenge)
+ else if (no_challenge)
{
/* No PIN was ever transmitted for this validation, so there is
nothing to check; the user must POST /challenge first. */
diff --git a/src/challenger/challenger-httpd_token.c b/src/challenger/challenger-httpd_token.c
@@ -222,6 +222,60 @@ post_iter (void *cls,
}
+/**
+ * Check the "Content-Length" header (if any) of @a connection against
+ * @a max_len. Same contract as TALER_MHD_check_content_length(), except
+ * that the error responses keep the "error" member required by RFC 6749
+ * section 5.2, which every other error of this endpoint carries.
+ *
+ * @param connection connection to check
+ * @param max_len largest permitted body length
+ * @param[out] mret set to the result to return if we answered the request
+ * @return true if a response was queued and the caller must return @a mret
+ */
+static bool
+check_content_length (struct MHD_Connection *connection,
+ unsigned long long max_len,
+ enum MHD_Result *mret)
+{
+ const char *cl;
+ unsigned long long cv;
+ char dummy;
+
+ cl = MHD_lookup_connection_value (connection,
+ MHD_HEADER_KIND,
+ MHD_HTTP_HEADER_CONTENT_LENGTH);
+ if (NULL == cl)
+ return false; /* chunked upload; the per-field cap catches oversize */
+ if (1 != sscanf (cl,
+ "%llu%c",
+ &cv,
+ &dummy))
+ {
+ GNUNET_break_op (0);
+ *mret = CH_reply_with_oauth_error (
+ connection,
+ MHD_HTTP_BAD_REQUEST,
+ "invalid_request",
+ TALER_EC_GENERIC_PARAMETER_MALFORMED,
+ MHD_HTTP_HEADER_CONTENT_LENGTH);
+ return true;
+ }
+ if (cv > max_len)
+ {
+ GNUNET_break_op (0);
+ *mret = CH_reply_with_oauth_error (
+ connection,
+ MHD_HTTP_CONTENT_TOO_LARGE,
+ "invalid_request",
+ TALER_EC_GENERIC_UPLOAD_EXCEEDS_LIMIT,
+ NULL);
+ return true;
+ }
+ return false;
+}
+
+
enum MHD_Result
CH_handler_token (struct CH_HandlerContext *hc,
const char *upload_data,
@@ -244,13 +298,19 @@ CH_handler_token (struct CH_HandlerContext *hc,
GNUNET_break_op (0);
return CH_reply_with_oauth_error (
hc->connection,
- MHD_HTTP_BAD_REQUEST,
+ MHD_HTTP_UNSUPPORTED_MEDIA_TYPE,
"invalid_request",
TALER_EC_GENERIC_PARAMETER_MALFORMED,
- "Content-Type");
+ MHD_HTTP_HEADER_CONTENT_TYPE);
+ }
+ {
+ enum MHD_Result mret;
+
+ if (check_content_length (hc->connection,
+ 2 * 1024,
+ &mret))
+ return mret;
}
- TALER_MHD_check_content_length (hc->connection,
- 2 * 1024);
return MHD_YES;
}
/* handle upload */
@@ -269,9 +329,10 @@ CH_handler_token (struct CH_HandlerContext *hc,
if (bc->too_big)
{
GNUNET_break_op (0);
- return TALER_MHD_reply_with_error (
+ return CH_reply_with_oauth_error (
hc->connection,
MHD_HTTP_CONTENT_TOO_LARGE,
+ "invalid_request",
TALER_EC_GENERIC_UPLOAD_EXCEEDS_LIMIT,
NULL);
}
@@ -371,9 +432,10 @@ CH_handler_token (struct CH_HandlerContext *hc,
{
case GNUNET_DB_STATUS_HARD_ERROR:
GNUNET_break (0);
- return TALER_MHD_reply_with_error (
+ return CH_reply_with_oauth_error (
hc->connection,
MHD_HTTP_INTERNAL_SERVER_ERROR,
+ "server_error",
TALER_EC_GENERIC_DB_FETCH_FAILED,
"get_client");
case GNUNET_DB_STATUS_SOFT_ERROR:
@@ -394,9 +456,10 @@ CH_handler_token (struct CH_HandlerContext *hc,
if (GNUNET_DB_STATUS_SOFT_ERROR == qs)
{
GNUNET_break (0);
- return TALER_MHD_reply_with_error (
+ return CH_reply_with_oauth_error (
hc->connection,
MHD_HTTP_INTERNAL_SERVER_ERROR,
+ "server_error",
TALER_EC_GENERIC_DB_SOFT_FAILURE,
"get_client");
}
@@ -405,6 +468,7 @@ CH_handler_token (struct CH_HandlerContext *hc,
bc->redirect_uri)) )
{
GNUNET_break_op (0);
+ GNUNET_free (client_url);
return CH_reply_with_oauth_error (
hc->connection,
MHD_HTTP_UNAUTHORIZED,
@@ -466,9 +530,10 @@ CH_handler_token (struct CH_HandlerContext *hc,
{
case GNUNET_DB_STATUS_HARD_ERROR:
GNUNET_break (0);
- return TALER_MHD_reply_with_error (
+ return CH_reply_with_oauth_error (
hc->connection,
MHD_HTTP_INTERNAL_SERVER_ERROR,
+ "server_error",
TALER_EC_GENERIC_DB_FETCH_FAILED,
"get_validation_pkce");
case GNUNET_DB_STATUS_SOFT_ERROR:
@@ -494,9 +559,10 @@ CH_handler_token (struct CH_HandlerContext *hc,
if (GNUNET_DB_STATUS_SOFT_ERROR == qs)
{
GNUNET_break (0);
- return TALER_MHD_reply_with_error (
+ return CH_reply_with_oauth_error (
hc->connection,
MHD_HTTP_INTERNAL_SERVER_ERROR,
+ "server_error",
TALER_EC_GENERIC_DB_SOFT_FAILURE,
"get_validation_pkce");
}
@@ -512,10 +578,13 @@ CH_handler_token (struct CH_HandlerContext *hc,
GNUNET_free (client_redirect_uri);
GNUNET_free (client_state);
GNUNET_free (code_challenge);
- return TALER_MHD_reply_with_error (
+ /* The value comes out of our own database, so a bad one is corrupt
+ server state, not a malformed request parameter. */
+ return CH_reply_with_oauth_error (
hc->connection,
MHD_HTTP_INTERNAL_SERVER_ERROR,
- TALER_EC_GENERIC_PARAMETER_MALFORMED,
+ "server_error",
+ TALER_EC_GENERIC_DB_INVARIANT_FAILURE,
"Invalid code_challenge_method");
}
@@ -744,9 +813,10 @@ CH_handler_token (struct CH_HandlerContext *hc,
{
case GNUNET_DB_STATUS_HARD_ERROR:
GNUNET_break (0);
- return TALER_MHD_reply_with_error (
+ return CH_reply_with_oauth_error (
hc->connection,
MHD_HTTP_INTERNAL_SERVER_ERROR,
+ "server_error",
TALER_EC_GENERIC_DB_STORE_FAILED,
"do_insert_token");
case GNUNET_DB_STATUS_SOFT_ERROR:
@@ -767,9 +837,10 @@ CH_handler_token (struct CH_HandlerContext *hc,
if (GNUNET_DB_STATUS_SOFT_ERROR == qs)
{
GNUNET_break (0);
- return TALER_MHD_reply_with_error (
+ return CH_reply_with_oauth_error (
hc->connection,
MHD_HTTP_INTERNAL_SERVER_ERROR,
+ "server_error",
TALER_EC_GENERIC_DB_SOFT_FAILURE,
"do_insert_token");
}
diff --git a/src/challenger/test-challenger-auth-errors.sh b/src/challenger/test-challenger-auth-errors.sh
@@ -95,18 +95,30 @@ fi
echo " OK"
# Check that the last response carried a 'WWW-Authenticate: Bearer' header
-# naming the given OAuth error.
+# naming the given OAuth error, with the realm and a description as
+# required/recommended by RFC 6750 section 3.
function check_www_authenticate() {
if ! grep -qi "^WWW-Authenticate:" "$LAST_HEADERS"
then
exit_fail "missing WWW-Authenticate header: $(cat $LAST_HEADERS)"
fi
+ if ! grep -qi "^WWW-Authenticate:.*Bearer.*realm=\"challenger\"" "$LAST_HEADERS"
+ then
+ exit_fail "expected WWW-Authenticate to carry realm=\"challenger\". Got: $(grep -i '^WWW-Authenticate:' $LAST_HEADERS)"
+ fi
if ! grep -qi "^WWW-Authenticate:.*Bearer.*error=\"$1\"" "$LAST_HEADERS"
then
exit_fail "expected WWW-Authenticate 'Bearer error=\"$1\"'. Got: $(grep -i '^WWW-Authenticate:' $LAST_HEADERS)"
fi
+ if ! grep -qi "^WWW-Authenticate:.*error_description=\"" "$LAST_HEADERS"
+ then
+ exit_fail "expected WWW-Authenticate to carry an error_description. Got: $(grep -i '^WWW-Authenticate:' $LAST_HEADERS)"
+ fi
}
+# RFC 6750 section 3: "If the request lacks any authentication information
+# [...] the resource server SHOULD NOT include an error code." So this
+# challenge must carry the realm and nothing else.
echo -n "/info without an Authorization header ..."
STATUS=$(curl "${BURL}/info" \
-D "$LAST_HEADERS" \
@@ -115,7 +127,14 @@ if [ "$STATUS" != "401" ]
then
exit_fail "Expected 401 Unauthorized. Got: $STATUS" $(cat $LAST_RESPONSE)
fi
-check_www_authenticate "invalid_request"
+if ! grep -qi "^WWW-Authenticate:.*Bearer.*realm=\"challenger\"" "$LAST_HEADERS"
+then
+ exit_fail "expected WWW-Authenticate 'Bearer realm=\"challenger\"'. Got: $(grep -i '^WWW-Authenticate:' $LAST_HEADERS)"
+fi
+if grep -qi "^WWW-Authenticate:.*error=" "$LAST_HEADERS"
+then
+ exit_fail "challenge for a request without credentials must not name an error code. Got: $(grep -i '^WWW-Authenticate:' $LAST_HEADERS)"
+fi
echo " OK"
echo -n "/info with a non-Bearer Authorization header ..."
diff --git a/src/challenger/test-challenger-badutf8.sh b/src/challenger/test-challenger-badutf8.sh
@@ -141,6 +141,32 @@ wget --tries=1 --timeout=5 "${BURL}/config" -o /dev/null -O /dev/null \
|| exit_fail "challenger-httpd died on a non-UTF-8 field name"
echo " OK"
+# Same class of bug via the JSON upload path: the address is echoed back
+# with GNUNET_JSON_pack_object_incref(), which GNUNET_assert()s on a
+# non-object, so a JSON body that is not an object used to abort the
+# daemon.
+for BODY in '[1,2]' '"a string"' 'null'
+do
+ echo -n "Submitting a JSON address that is not an object (${BODY})..."
+ STATUS=$(curl "${BURL}/challenge/${NONCE}" \
+ -X POST \
+ -H "Accept: application/json" \
+ -H "Content-Type: application/json" \
+ -d "${BODY}" \
+ -w "%{http_code}" -s -o $LAST_RESPONSE) \
+ || exit_fail "challenger-httpd died on a non-object JSON address ${BODY}"
+ if [ "$STATUS" != "400" ]
+ then
+ exit_fail "Expected 400 Bad Request. Got: $STATUS" $(cat $LAST_RESPONSE)
+ fi
+ echo " OK"
+
+ echo -n "Checking that challenger-httpd is still alive..."
+ wget --tries=1 --timeout=5 "${BURL}/config" -o /dev/null -O /dev/null \
+ || exit_fail "challenger-httpd died on a non-object JSON address ${BODY}"
+ echo " OK"
+done
+
# The rejected requests must not have consumed anything: a well-formed
# address is still accepted afterwards.
echo -n "Submitting a well-formed address..."
diff --git a/src/challenger/test-challenger-exhaustion.sh b/src/challenger/test-challenger-exhaustion.sh
@@ -310,4 +310,61 @@ then
fi
echo " OK"
+# Now the other end of the scale: a validation with nothing left at all.
+echo -n "Driving a validation to terminal exhaustion ..."
+new_validation
+for a in one two three
+do
+ submit_address "${a}"
+ expect_transmitted "true" "the submission of address '${a}'"
+ burn_guesses
+done
+# Spend what remains of the PIN transmission budget. burn_guesses() cannot
+# be reused for the last round: once addresses, transmissions and guesses
+# are all spent, the response that consumes the final guess is already the
+# terminal one, so it is a 429 rather than the 403 burn_guesses() expects.
+while true
+do
+ TRANSMISSIONS_LEFT=$(jq -r .pin_transmissions_left < "$LAST_RESPONSE")
+ if [ "${TRANSMISSIONS_LEFT}" -le 0 ]
+ then
+ break
+ fi
+ await_retransmission
+ submit_address "three"
+ expect_transmitted "true" "a retransmission while spending the budget"
+ for i in 1 2 3
+ do
+ solve "$(( (10#${PIN} + i) % 100000000 ))"
+ done
+done
+echo " OK"
+
+# Since v8 this uses the same InvalidPinResponse shape as every other
+# /solve failure, so that a client only ever parses one body.
+echo -n "Terminal exhaustion uses the InvalidPinResponse shape ..."
+solve "11111111"
+CODE=$(jq -r .code < "$LAST_RESPONSE")
+if [ "$STATUS" != "429" ] || [ "$CODE" != "9757" ]
+then
+ exit_fail "/solve: expected 429 with code 9757 (TOO_MANY_ATTEMPTS) once nothing is left. Got: $STATUS / $CODE" $(cat $LAST_RESPONSE)
+fi
+if [ "$(jq -r .type < "$LAST_RESPONSE")" != "pending" ]
+then
+ exit_fail "/solve: terminal 429 must use the InvalidPinResponse shape. Got: $(cat $LAST_RESPONSE)"
+fi
+for field in addresses_left pin_transmissions_left auth_attempts_left
+do
+ if [ "$(jq -r ".${field}" < "$LAST_RESPONSE")" != "0" ]
+ then
+ exit_fail "/solve: expected ${field}=0 on the terminal 429. Got: $(cat $LAST_RESPONSE)"
+ fi
+done
+if [ "$(jq -r .exhausted < "$LAST_RESPONSE")" != "true" ] || \
+ [ "$(jq -r .no_challenge < "$LAST_RESPONSE")" != "false" ]
+then
+ exit_fail "/solve: expected exhausted=true and no_challenge=false. Got: $(cat $LAST_RESPONSE)"
+fi
+echo " OK"
+
exit 0