challenger

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

commit 096cfadd1008089cdbe54d79dc12090a87e83d0c
parent b07ddd6d1ec13de17bd4c51b2836c064b4ff920c
Author: Christian Grothoff <christian@grothoff.org>
Date:   Thu,  6 Aug 2026 09:49:18 +0200

do not die on non-UTF-8 input

Diffstat:
Msrc/challenger/meson.build | 7+++++++
Asrc/challenger/test-challenger-badutf8.sh | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/challengerdb/do_challenge_address.sql | 35++++++++++++++++++++++++++++++-----
3 files changed, 196 insertions(+), 5 deletions(-)

diff --git a/src/challenger/meson.build b/src/challenger/meson.build @@ -12,6 +12,7 @@ check_SCRIPTS = [ 'test-challenger-pkce', 'test-challenger-revisit', 'test-challenger-badutf8', + 'test-challenger-pinlimit', ] test_helper_cat = configure_file(input: 'cat.sh', output: 'cat.sh', copy: true) @@ -28,6 +29,12 @@ test_conf = configure_file( copy: true, ) +test_conf = configure_file( + input: 'test-challenger-pinlimit.conf', + output: 'test-challenger-pinlimit.conf', + copy: true, +) + foreach s : check_SCRIPTS tscript = '@0@.sh'.format(s) test_exe = configure_file(input: tscript, output: tscript, copy: true) diff --git a/src/challenger/test-challenger-badutf8.sh b/src/challenger/test-challenger-badutf8.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# This file is in the public domain. +# +# Tests that a /challenge upload with a field that is not valid UTF-8 +# is rejected with 400 and does NOT take the daemon down. +# +# Regression test: json_string() returns NULL for invalid UTF-8, and the +# resulting json_object_set_new() failure used to be caught by a +# GNUNET_assert(), so a single unauthenticated POST aborted +# challenger-httpd. + +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" "$FILENAME" + wait +} + +LAST_RESPONSE=$(mktemp responseXXXXXX.log) +FILENAME="test-challenger-badutf8.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" + +echo -n "Setup new validation process..." +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. Got: $STATUS" $(cat $LAST_RESPONSE) +fi +NONCE=$(jq -r .nonce < "$LAST_RESPONSE") +echo " OK" + +# Note: '--data' (not '--data-urlencode') so that the '%FF' reaches the +# server verbatim and is URL-decoded into a lone 0xFF byte, which is not +# valid UTF-8. +echo -n "Submitting address with a non-UTF-8 value..." +STATUS=$(curl "${BURL}/challenge/${NONCE}" \ + -X POST \ + -H "Accept: application/json" \ + --data "filename=%FF" \ + -w "%{http_code}" -s -o $LAST_RESPONSE || echo "000") + +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-UTF-8 upload" +echo " OK" + +echo -n "Submitting address with a non-UTF-8 field name..." +STATUS=$(curl "${BURL}/challenge/${NONCE}" \ + -X POST \ + -H "Accept: application/json" \ + --data "file%FFname=x" \ + -w "%{http_code}" -s -o $LAST_RESPONSE || echo "000") + +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-UTF-8 field name" +echo " OK" + +# The rejected requests must not have consumed anything: a well-formed +# address is still accepted afterwards. +echo -n "Submitting a well-formed address..." +STATUS=$(curl "${BURL}/challenge/${NONCE}" \ + -X POST \ + -H "Accept: application/json" \ + --data-urlencode "filename=${FILENAME}" \ + -w "%{http_code}" -s -o $LAST_RESPONSE) + +if [ "$STATUS" != "200" ] +then + exit_fail "Expected 200 OK. Got: $STATUS" $(cat $LAST_RESPONSE) +fi +echo " OK" + +exit 0 diff --git a/src/challengerdb/do_challenge_address.sql b/src/challengerdb/do_challenge_address.sql @@ -39,6 +39,7 @@ AS $$ DECLARE my_status RECORD; my_do_update BOOL; + my_address_differs BOOL; BEGIN my_do_update = FALSE; @@ -87,8 +88,25 @@ THEN END IF; out_solved=FALSE; +-- Two addresses are the same address if they are the same JSON *value*. +-- Comparing the raw text instead would make a purely cosmetic difference -- +-- a different field order, redundant whitespace -- count as a different +-- address, with two consequences. Firstly, the daemon itself re-appends +-- 'read_only' to the address as the *last* field (see +-- challenger-httpd_challenge.c), so an unchanged address can come back here +-- with its fields in a different order and silently cost an honest user one +-- of their address attempts. Secondly, the budget is deliberately +-- 'address_attempts_left' addresses times 'pin_transmissions_left' PINs +-- each; if cosmetic variants counted as distinct addresses, all of those +-- messages could be aimed at a single recipient. Comparing as JSONB also +-- makes this agree with the C layer, which compares addresses with +-- json_equal() in addr_equal(). +my_address_differs = ( (my_status.address IS NOT NULL) AND + (in_address::JSONB IS DISTINCT FROM + my_status.address::JSONB) ); + IF ( (0 = my_status.address_attempts_left) AND - (in_address != my_status.address) ) + my_address_differs ) THEN out_address_refused=TRUE; out_last_pin=0; @@ -96,13 +114,20 @@ THEN END IF; out_address_refused=FALSE; -IF ( (my_status.address IS NULL) OR - (in_address != my_status.address) ) +IF ( my_address_differs OR + (my_status.address IS NULL) ) THEN - -- we are changing the address, update counters + -- We are changing the address, update counters. Refilling + -- 'pin_transmissions_left' and clearing the retransmission cooldown is + -- intentional: the user gets a fixed number of addresses and, for each + -- address, a fixed number of PIN transmissions, so that a user who + -- mistyped their address does not have to wait out the cooldown of a + -- message that went to somebody else. my_status.address_attempts_left = GREATEST(0,my_status.address_attempts_left - 1); - my_status.address = in_address; + -- Store the canonical (JSONB) rendering, so that what is on file does not + -- depend on the field order the client happened to use. + my_status.address = in_address::JSONB::TEXT; my_status.pin_transmissions_left = 3; my_status.last_tx_time = 0; my_do_update=TRUE;