challenger

OAuth 2.0-based authentication service that validates user can receive messages at a certain address
Log | Files | Refs | Submodules | README | LICENSE

commit 4cce9f30d17521bdbf2207ad526c9624f164cd2a
parent 06b57c086347ad986c37c0719dbacdcd31dfee9b
Author: Christian Grothoff <christian@grothoff.org>
Date:   Thu,  6 Aug 2026 19:29:41 +0200

fix /setup handling of POSTed body, add test

Diffstat:
Msrc/challenger/challenger-httpd_setup.c | 28+++++++++++++++++++++++++++-
Msrc/challenger/meson.build | 1+
Asrc/challenger/test-challenger-setup-address.sh | 343+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 371 insertions(+), 1 deletion(-)

diff --git a/src/challenger/challenger-httpd_setup.c b/src/challenger/challenger-httpd_setup.c @@ -55,6 +55,13 @@ struct SetupContext */ json_t *root; + /** + * Did the client actually send a request body? The body is optional + * for this endpoint, so we must not hand an empty upload to the JSON + * parser (which would reject it as malformed). + */ + bool have_upload; + }; @@ -98,8 +105,16 @@ CH_handler_setup (struct CH_HandlerContext *hc, hc->ctx = sc; return MHD_YES; } + /* Remember that a body is coming. We must keep calling the parser after + this point even once *upload_data_size has dropped back to zero: that + final call is the one where GNUNET_MHD_post_parser() actually decodes + the buffered bytes. Conversely, if no body ever arrives we must not + call the parser at all, as it would report an empty document as + malformed JSON -- the body of this endpoint is optional. */ + if (0 != *upload_data_size) + sc->have_upload = true; if ( (NULL == sc->root) && - (0 != *upload_data_size) ) + (sc->have_upload) ) { /* parse byte stream upload into JSON */ enum GNUNET_GenericReturnValue res; @@ -133,6 +148,17 @@ CH_handler_setup (struct CH_HandlerContext *hc, stderr, JSON_INDENT (2)); #endif + if (! json_is_object (sc->root)) + { + /* The address is stored verbatim and later packed with + GNUNET_JSON_pack_object_steal() by /authorize, which asserts on + anything that is not an object; refuse it here instead. */ + GNUNET_break_op (0); + return TALER_MHD_reply_with_error (hc->connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "address"); + } } { diff --git a/src/challenger/meson.build b/src/challenger/meson.build @@ -23,6 +23,7 @@ check_SCRIPTS = [ 'test-challenger-exhaustion', 'test-challenger-auth-errors', 'test-challenger-upload-limits', + 'test-challenger-setup-address', ] test_helper_cat = configure_file(input: 'cat.sh', output: 'cat.sh', copy: true) diff --git a/src/challenger/test-challenger-setup-address.sh b/src/challenger/test-challenger-setup-address.sh @@ -0,0 +1,343 @@ +#!/usr/bin/env bash +# This file is in the public domain. +# +# Tests that an address passed in the body of POST /setup/$CLIENT_ID is +# actually stored and honoured, i.e. protocol v4's "pre-initialize the +# address" feature and the 'read_only' bit documented for +# ChallengeSetupRequest. +# +# Regression test: CH_handler_setup() gates the body parse on +# '0 != *upload_data_size', but GNUNET_MHD_post_parser() only finalizes the +# upload (and thus only runs json_loadb()) on the callback where +# *upload_data_size IS zero. That callback was therefore skipped, sc->root +# stayed NULL, and the uploaded address was silently discarded: /authorize +# reported last_address=null and /challenge happily accepted any address, +# so 403 TALER_EC_CHALLENGER_CLIENT_FORBIDDEN_READ_ONLY was unreachable. +# +# None of the other tests catch this because they all call /setup with an +# empty body ("-d ''"). + +set -eu + +# Exit, with status code "skip" (no 'real' failure) +function exit_skip() { + echo " SKIP: $1" + exit 77 +} + +# Exit, with error message (hard failure) +function exit_fail() { + echo " FAIL: $@" + exit 1 +} + +# Cleanup to run whenever we exit +function cleanup() +{ + for n in $(jobs -p) + do + kill $n 2> /dev/null || true + done + rm -f "$LAST_RESPONSE" "$FIXED_FILE" "$OTHER_FILE" + wait +} + +LAST_RESPONSE=$(mktemp responseXXXXXX.log) +FIXED_FILE="test-challenger-setup-address.txt" +OTHER_FILE="test-challenger-setup-other.txt" + +# Install cleanup handler (except for kill -9) +trap cleanup EXIT + +export PATH="$PATH:." + +echo -n "Testing for jq" +jq -h > /dev/null || exit_skip "jq required" +echo " FOUND" +echo -n "Testing for curl" +curl -h > /dev/null || exit_skip "curl required" +echo " FOUND" +echo -n "Testing for wget" +wget -h > /dev/null || exit_skip "wget required" +echo " FOUND" +echo -n "Testing for challenger-httpd ..." +challenger-httpd -h > /dev/null || exit_skip "challenger-httpd required" +echo " FOUND" + +CONF="test-challenger.conf" +BURL="http://localhost:9967" +REDIRECT_URI="http://client.example.com/" + +echo -n "Initialize challenger database ..." +challenger-dbinit -r -c "${CONF}" &> dbinit.log +echo " OK" + +echo -n "Add challenger client ..." +CLIENT_SECRET="secret-token:secret" +challenger-admin -c "${CONF}" -a "${CLIENT_SECRET}" "${REDIRECT_URI}" &> admin.log +echo " OK" +# We just reset the DB, thus the client ID must be 1 here: +CLIENT_ID=1 + +echo -n "Start challenger-httpd ..." +challenger-httpd -L INFO -c "${CONF}" &> httpd.log & + +# Wait for challenger to be available +for n in $(seq 1 50) +do + echo -n "." + sleep 0.2 + OK=0 + wget --tries=1 --timeout=1 "${BURL}/config" -o /dev/null -O /dev/null >/dev/null || continue + OK=1 + break +done +if [ 1 != $OK ] +then + exit_skip "Failed to launch challenger service" +fi +echo " OK" + +# Run /setup with $1 as the JSON request body, leaving the nonce in NONCE. +function setup_with_body() +{ + local STATUS + STATUS=$(curl "${BURL}/setup/${CLIENT_ID}" \ + -H "Authorization: Bearer ${CLIENT_SECRET}" \ + -H "Content-Type: application/json" \ + -d "$1" \ + -w "%{http_code}" -s -o $LAST_RESPONSE) + if [ "$STATUS" != "200" ] + then + exit_fail "Expected 200 OK from /setup. Got: $STATUS" $(cat $LAST_RESPONSE) + fi + NONCE=$(jq -r .nonce < "$LAST_RESPONSE") +} + +# GET /authorize for $NONCE, leaving the JSON body in $LAST_RESPONSE. +function authorize() +{ + local STATUS + STATUS=$(curl "${BURL}/authorize/${NONCE}" \ + -G \ + -H "Accept: application/json" \ + --data-urlencode "response_type=code" \ + --data-urlencode "client_id=${CLIENT_ID}" \ + --data-urlencode "redirect_uri=${REDIRECT_URI}" \ + --data-urlencode "state=state" \ + -w "%{http_code}" -s -o $LAST_RESPONSE) + if [ "$STATUS" != "200" ] + then + exit_fail "Expected 200 OK from /authorize. Got: $STATUS" $(cat $LAST_RESPONSE) + fi +} + + +echo -n "/setup stores a plain pre-initialized address ..." +setup_with_body "{\"filename\":\"${FIXED_FILE}\"}" +authorize +if [ "$(jq -r '.last_address.filename' < "$LAST_RESPONSE")" != "${FIXED_FILE}" ] +then + exit_fail "/authorize did not return the address given to /setup:" \ + "$(cat $LAST_RESPONSE)" +fi +# Without 'read_only' the address must still be editable. +if [ "$(jq -r .fix_address < "$LAST_RESPONSE")" != "false" ] +then + exit_fail "/authorize fixed an address that was not marked read_only:" \ + "$(cat $LAST_RESPONSE)" +fi +echo " OK" + +echo -n "/setup honours the read_only bit ..." +setup_with_body "{\"read_only\":true,\"filename\":\"${FIXED_FILE}\"}" +authorize +if [ "$(jq -r '.last_address.filename' < "$LAST_RESPONSE")" != "${FIXED_FILE}" ] +then + exit_fail "/authorize did not return the read-only address:" \ + "$(cat $LAST_RESPONSE)" +fi +if [ "$(jq -r .fix_address < "$LAST_RESPONSE")" != "true" ] +then + exit_fail "/authorize did not report fix_address for a read_only address:" \ + "$(cat $LAST_RESPONSE)" +fi +if [ "$(jq -r .changes_left < "$LAST_RESPONSE")" != "0" ] +then + exit_fail "/authorize left address changes for a read_only address:" \ + "$(cat $LAST_RESPONSE)" +fi +echo " OK" + +echo -n "/challenge refuses to change a read_only address ..." +STATUS=$(curl "${BURL}/challenge/${NONCE}" \ + -X POST \ + -H "Accept: application/json" \ + --data-urlencode "filename=${OTHER_FILE}" \ + -w "%{http_code}" -s -o $LAST_RESPONSE) +if [ "$STATUS" != "403" ] +then + exit_fail "Expected 403 when changing a read_only address. Got: $STATUS" \ + "$(cat $LAST_RESPONSE)" +fi +CODE=$(jq -r .code < "$LAST_RESPONSE") +# 9760 == TALER_EC_CHALLENGER_CLIENT_FORBIDDEN_READ_ONLY +if [ "$CODE" != "9760" ] +then + exit_fail "Expected code 9760 (CLIENT_FORBIDDEN_READ_ONLY). Got: $CODE" \ + "$(cat $LAST_RESPONSE)" +fi +if [ -f "${OTHER_FILE}" ] +then + exit_fail "A challenge was transmitted to the refused address ${OTHER_FILE}" +fi +echo " OK" + +echo -n "/challenge accepts the unchanged read_only address ..." +STATUS=$(curl "${BURL}/challenge/${NONCE}" \ + -X POST \ + -H "Accept: application/json" \ + --data-urlencode "filename=${FIXED_FILE}" \ + -w "%{http_code}" -s -o $LAST_RESPONSE) +if [ "$STATUS" != "200" ] +then + exit_fail "Expected 200 OK from /challenge. Got: $STATUS" \ + "$(cat $LAST_RESPONSE)" +fi +if [ ! -f "${FIXED_FILE}" ] +then + exit_fail "No challenge was transmitted to ${FIXED_FILE}" +fi +echo " OK" + +echo -n "The PIN sent to the fixed address solves the challenge ..." +PIN=$(awk '{print $5}' < "${FIXED_FILE}") +STATUS=$(curl "${BURL}/solve/${NONCE}" \ + -X POST \ + -H "Accept: application/json" \ + --data-urlencode "pin=${PIN}" \ + -w "%{http_code}" -s -o $LAST_RESPONSE) +if [ "$STATUS" != "200" ] +then + exit_fail "Expected 200 OK from /solve. Got: $STATUS" \ + "$(cat $LAST_RESPONSE)" +fi +if [ "$(jq -r .type < "$LAST_RESPONSE")" != "completed" ] +then + exit_fail "Expected type=completed from /solve. Got: $(cat $LAST_RESPONSE)" +fi +echo " OK" + +# The body of /setup is optional, so an absent or empty body must keep +# working. Every other test script relies on this ("-d ''"), and a fix +# that simply always runs the JSON parser breaks all of them: the parser +# reports an empty document as malformed JSON. +echo -n "/setup still accepts an empty body ..." +STATUS=$(curl "${BURL}/setup/${CLIENT_ID}" \ + -H "Authorization: Bearer ${CLIENT_SECRET}" \ + -d '' \ + -w "%{http_code}" -s -o $LAST_RESPONSE) +if [ "$STATUS" != "200" ] +then + exit_fail "Expected 200 OK for an empty /setup body. Got: $STATUS" \ + "$(cat $LAST_RESPONSE)" +fi +echo " OK" + +echo -n "/setup still accepts a request with no body at all ..." +STATUS=$(curl "${BURL}/setup/${CLIENT_ID}" \ + -X POST \ + -H "Authorization: Bearer ${CLIENT_SECRET}" \ + -w "%{http_code}" -s -o $LAST_RESPONSE) +if [ "$STATUS" != "200" ] +then + exit_fail "Expected 200 OK for a /setup without a body. Got: $STATUS" \ + "$(cat $LAST_RESPONSE)" +fi +echo " OK" + +echo -n "/setup accepts an empty JSON object ..." +STATUS=$(curl "${BURL}/setup/${CLIENT_ID}" \ + -H "Authorization: Bearer ${CLIENT_SECRET}" \ + -H "Content-Type: application/json" \ + -d '{}' \ + -w "%{http_code}" -s -o $LAST_RESPONSE) +if [ "$STATUS" != "200" ] +then + exit_fail "Expected 200 OK for an empty JSON object. Got: $STATUS" \ + "$(cat $LAST_RESPONSE)" +fi +echo " OK" + +# A chunked upload has no Content-Length, so the body is only recognized +# by the fact that data actually arrived. +echo -n "/setup stores an address sent with chunked encoding ..." +STATUS=$(curl "${BURL}/setup/${CLIENT_ID}" \ + -H "Authorization: Bearer ${CLIENT_SECRET}" \ + -H "Content-Type: application/json" \ + -H "Transfer-Encoding: chunked" \ + -d "{\"filename\":\"${FIXED_FILE}\"}" \ + -w "%{http_code}" -s -o $LAST_RESPONSE) +if [ "$STATUS" != "200" ] +then + exit_fail "Expected 200 OK for a chunked /setup body. Got: $STATUS" \ + "$(cat $LAST_RESPONSE)" +fi +NONCE=$(jq -r .nonce < "$LAST_RESPONSE") +authorize +if [ "$(jq -r '.last_address.filename' < "$LAST_RESPONSE")" != "${FIXED_FILE}" ] +then + exit_fail "/authorize did not return the chunked address:" \ + "$(cat $LAST_RESPONSE)" +fi +echo " OK" + +# The stored address is later packed with GNUNET_JSON_pack_object_steal() +# by /authorize, which GNUNET_assert()s on anything that is not an object. +# Storing a non-object would therefore take the daemon down on the very +# next /authorize, so /setup has to refuse it up front. +echo -n "A non-object /setup body is rejected ..." +STATUS=$(curl "${BURL}/setup/${CLIENT_ID}" \ + -H "Authorization: Bearer ${CLIENT_SECRET}" \ + -H "Content-Type: application/json" \ + -d '[1,2]' \ + -w "%{http_code}" -s -o $LAST_RESPONSE) +if [ "$STATUS" != "400" ] +then + exit_fail "Expected 400 for a JSON array body. Got: $STATUS" \ + "$(cat $LAST_RESPONSE)" +fi +STATUS=$(curl "${BURL}/config" -w "%{http_code}" -s -o /dev/null) +if [ "$STATUS" != "200" ] +then + exit_fail "Service died after a non-object /setup body" +fi +echo " OK" + +echo -n "A malformed /setup body is rejected ..." +STATUS=$(curl "${BURL}/setup/${CLIENT_ID}" \ + -H "Authorization: Bearer ${CLIENT_SECRET}" \ + -H "Content-Type: application/json" \ + -d '{not json' \ + -w "%{http_code}" -s -o $LAST_RESPONSE) +if [ "$STATUS" != "400" ] +then + exit_fail "Expected 400 for a body that is not JSON. Got: $STATUS" \ + "$(cat $LAST_RESPONSE)" +fi +echo " OK" + +echo -n "A non-boolean read_only is rejected ..." +STATUS=$(curl "${BURL}/setup/${CLIENT_ID}" \ + -H "Authorization: Bearer ${CLIENT_SECRET}" \ + -H "Content-Type: application/json" \ + -d '{"read_only":"yes"}' \ + -w "%{http_code}" -s -o $LAST_RESPONSE) +if [ "$STATUS" != "400" ] +then + exit_fail "Expected 400 for a non-boolean read_only. Got: $STATUS" \ + "$(cat $LAST_RESPONSE)" +fi +echo " OK" + +exit 0