From 113bac9e7c59dcdfd09e2befe6b0cf18117a9c46 Mon Sep 17 00:00:00 2001 From: be-coder-08 Date: Thu, 6 Aug 2026 00:08:37 -0500 Subject: [PATCH] fix(pr-merge): harden provider message path --- docs/scratchpads/pr-merge-message-field.md | 14 +++ .../mosaic/framework/tools/git/pr-merge.sh | 89 ++++++++++------- .../tools/git/test-pr-merge-message-field.sh | 96 +++++++++++++++---- 3 files changed, 143 insertions(+), 56 deletions(-) diff --git a/docs/scratchpads/pr-merge-message-field.md b/docs/scratchpads/pr-merge-message-field.md index 1827dffb..b86112c5 100644 --- a/docs/scratchpads/pr-merge-message-field.md +++ b/docs/scratchpads/pr-merge-message-field.md @@ -83,3 +83,17 @@ Add an optional, identity-checked Gitea squash message to `pr-merge.sh` so genui ## Current hold point PR `mosaicstack/stack#1066` is open. Its first frozen head `f4b162fa…` was terminal-green in Woodpecker `mosaic` pipeline `#2225`, but that evidence becomes stale when the canonical port moves the head. The deployed wrapper remains baseline `08a65e85…`; no manual copy will occur. Canonical port tests, commit amendment, rebase, one guarded force-with-lease, exact-head CI, and new independent review remain. Even after source merge, activation remains blocked on an assigned package-release/reseed owner and installed-byte read-back. + +## Security review 96 remediation + +Exact reviewed predecessor head: `1ceb11058f64dd7f4a817ceb2124f980a1c4dd23`. + +RED-first focused harness produced 10 named failures: all curl calls lacked size/time/connect bounds; raw ESC email reached mutation; oversized and stalled curl failures were discarded and reached mutation; nonempty Basic output with resolver rc 91 authorized mutation. + +Security remediation: + +- Removed the cross-principal HTTP-401 Basic fallback. Both inspection-401 and merge-401 paths now refuse without Basic resolution or mutation; `get_gitea_basic_auth` references in the merge subject are 0. +- Applied `--max-filesize`, `--max-time`, and `--connect-timeout` to all 3/3 provider curl sites and fail closed on curl transport rc at all 3/3 sites. +- Required linked email bytes to be ASCII and printable before constructing `MergeMessageField`; guarded construction sites 1/1. + +GREEN: message-field, exact-head, empty-UID/API, queue branch/repository/SHA, bash syntax, ShellCheck, and diff check pass. R7 total-removal mutants went RED: email guard 3 rows; bound switches 1 row; transport-rc guards 4 rows; HTTP-401 refusal 3 rows. R7 bound: mutants prove total removal only; explicit denominators above prove site coverage. diff --git a/packages/mosaic/framework/tools/git/pr-merge.sh b/packages/mosaic/framework/tools/git/pr-merge.sh index 20e02524..495cbb47 100755 --- a/packages/mosaic/framework/tools/git/pr-merge.sh +++ b/packages/mosaic/framework/tools/git/pr-merge.sh @@ -178,6 +178,20 @@ else: LAST_GITEA_HTTP_CODE="000" LAST_GITEA_ERROR="" MERGE_TEMP_DIRS=() +GITEA_CURL_MAX_BYTES="${MOSAIC_GITEA_CURL_MAX_BYTES:-1048576}" +GITEA_CURL_MAX_TIME="${MOSAIC_GITEA_CURL_MAX_TIME_SEC:-30}" +GITEA_CURL_CONNECT_TIMEOUT="${MOSAIC_GITEA_CURL_CONNECT_TIMEOUT_SEC:-10}" +for bound in "$GITEA_CURL_MAX_BYTES" "$GITEA_CURL_MAX_TIME" "$GITEA_CURL_CONNECT_TIMEOUT"; do + if [[ ! "$bound" =~ ^[1-9][0-9]*$ ]]; then + echo "Error: Gitea curl bounds must be positive integers; refusing request." >&2 + exit 1 + fi +done +GITEA_CURL_BOUNDS=( + --max-filesize "$GITEA_CURL_MAX_BYTES" + --max-time "$GITEA_CURL_MAX_TIME" + --connect-timeout "$GITEA_CURL_CONNECT_TIMEOUT" +) format_gitea_error_response() { local response_file="$1" @@ -219,7 +233,7 @@ trap 'exit 143' TERM fetch_gitea_pr_head() { local host="$1" auth_mode="$2" credential="$3" work_root="$4" - local response_file raw_code api_url auth_config + local response_file raw_code api_url auth_config curl_rc response_file=$(mktemp "$work_root/pr-merge-pr.XXXXXX") api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}" if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then @@ -227,10 +241,15 @@ fetch_gitea_pr_head() { rm -f "$response_file" return 1 fi - raw_code=$(printf '%s\n' "$auth_config" | \ - curl -sS -K - -w '%{http_code}' -o "$response_file" \ - -H "User-Agent: curl/8" "$api_url" || true) + raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$response_file" \ + -H "User-Agent: curl/8" "$api_url" <<<"$auth_config") + curl_rc=$? LAST_GITEA_HTTP_CODE="${raw_code:-000}" + if [[ "$curl_rc" -ne 0 ]]; then + LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)" + rm -f "$response_file" + return 1 + fi if [[ ! "$raw_code" =~ ^2 ]]; then LAST_GITEA_ERROR=$(format_gitea_error_response "$response_file") rm -f "$response_file" @@ -259,7 +278,7 @@ PY fetch_gitea_pr_commits() { local host="$1" auth_mode="$2" credential="$3" work_root="$4" - local page page_file combined_file merged_file raw_code page_count api_url auth_config + local page page_file combined_file merged_file raw_code page_count api_url auth_config curl_rc mkdir -p "$work_root" if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then echo "Error: Could not construct Gitea authentication config; refusing request." >&2 @@ -272,10 +291,15 @@ fetch_gitea_pr_commits() { while true; do page_file=$(mktemp "$work_root/pr-merge-commits-page.XXXXXX") api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/commits?limit=50&page=${page}" - raw_code=$(printf '%s\n' "$auth_config" | \ - curl -sS -K - -w '%{http_code}' -o "$page_file" \ - -H "User-Agent: curl/8" "$api_url" || true) + raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$page_file" \ + -H "User-Agent: curl/8" "$api_url" <<<"$auth_config") + curl_rc=$? LAST_GITEA_HTTP_CODE="${raw_code:-000}" + if [[ "$curl_rc" -ne 0 ]]; then + LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)" + rm -f "$page_file" "$combined_file" + return 1 + fi if [[ ! "$raw_code" =~ ^2 ]]; then LAST_GITEA_ERROR=$(format_gitea_error_response "$page_file") rm -f "$page_file" "$combined_file" @@ -399,7 +423,12 @@ for item in commits: file=sys.stderr, ) raise SystemExit(75) - if not re.fullmatch(r"[A-Za-z0-9_.-]+", login) or not re.fullmatch(r"[^<>\s]+@[^<>\s]+", email): + if ( + not email.isascii() + or not email.isprintable() + or not re.fullmatch(r"[A-Za-z0-9_.-]+", login) + or not re.fullmatch(r"[^<>\s]+@[^<>\s]+", email) + ): print( f"BLOCK: commit {sha!r} has unusable linked identity " f"author.login={login!r}, commit.author.email={email!r}; refusing merge; " @@ -445,7 +474,7 @@ PY merge_gitea_api_attempt() { local host="$1" auth_mode="$2" credential="$3" - local api_url attempt_dir body_file raw_code commits_file fields_file context_file head_file payload_file work_root attempt_rc auth_config + local api_url attempt_dir body_file raw_code commits_file fields_file context_file head_file payload_file work_root attempt_rc auth_config curl_rc LAST_GITEA_HTTP_CODE="000" LAST_GITEA_ERROR="" api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge" @@ -520,12 +549,18 @@ PY rm -f "$body_file" "$payload_file" return 1 fi - raw_code=$(printf '%s\n' "$auth_config" | \ - curl -sS -K - -w '%{http_code}' -o "$body_file" \ - -X POST -H "User-Agent: curl/8" \ - -H 'Content-Type: application/json' \ - --data-binary "@$payload_file" "$api_url" || true) + raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$body_file" \ + -X POST -H "User-Agent: curl/8" \ + -H 'Content-Type: application/json' \ + --data-binary "@$payload_file" "$api_url" <<<"$auth_config") + curl_rc=$? LAST_GITEA_HTTP_CODE="${raw_code:-000}" + if [[ "$curl_rc" -ne 0 ]]; then + LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)" + rm -f "$body_file" "$payload_file" + rm -rf -- "$attempt_dir" + return 1 + fi if [[ ! "$raw_code" =~ ^2 ]]; then LAST_GITEA_ERROR=$(format_gitea_error_response "$body_file") fi @@ -535,7 +570,7 @@ PY } merge_gitea_with_api() { - local host="$1" token basic_auth attempt_rc + local host="$1" token attempt_rc if ! token=$(get_gitea_token "$host"); then echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2 @@ -554,28 +589,10 @@ merge_gitea_with_api() { return 75 fi if [[ "$LAST_GITEA_HTTP_CODE" != "401" ]]; then - echo "Error: Gitea API merge failed with token credential (HTTP ${LAST_GITEA_HTTP_CODE:-000}).${LAST_GITEA_ERROR:+ Provider response: $LAST_GITEA_ERROR} Basic Auth fallback is allowed only after HTTP 401." >&2 + echo "Error: Gitea API merge failed with the identity-bound token (HTTP ${LAST_GITEA_HTTP_CODE:-000}).${LAST_GITEA_ERROR:+ Provider response: $LAST_GITEA_ERROR}" >&2 return 1 fi - echo "Token credential received HTTP 401; retrying inspection and merge with configured Basic Auth." >&2 - - basic_auth=$(get_gitea_basic_auth "$host" || true) - if [[ -n "$basic_auth" ]]; then - if merge_gitea_api_attempt "$host" basic "$basic_auth"; then - return 0 - else - attempt_rc=$? - fi - if [[ "$attempt_rc" -eq 75 ]]; then - return 75 - fi - fi - - if [[ -z "$token" && -z "$basic_auth" ]]; then - echo "Error: No Gitea credential is available for the merge operation." >&2 - else - echo "Error: Gitea API merge failed for all configured credentials (last HTTP ${LAST_GITEA_HTTP_CODE:-000}).${LAST_GITEA_ERROR:+ Provider response: $LAST_GITEA_ERROR}" >&2 - fi + echo "Error: Gitea API rejected the identity-bound token with HTTP 401; refusing cross-principal credential fallback." >&2 return 1 } diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-message-field.sh b/packages/mosaic/framework/tools/git/test-pr-merge-message-field.sh index 4f1fd8fe..3a82524a 100755 --- a/packages/mosaic/framework/tools/git/test-pr-merge-message-field.sh +++ b/packages/mosaic/framework/tools/git/test-pr-merge-message-field.sh @@ -41,7 +41,7 @@ get_gitea_basic_auth() { printf 'resolved\n' >> "${MOSAIC_TEST_BASIC_RESOLUTION_LOG:?}" if [[ "${MOSAIC_TEST_BASIC_AVAILABLE:-false}" == "true" ]]; then printf 'fixture-user:fixture-password\n' - return 0 + return "${MOSAIC_TEST_BASIC_RC:-0}" fi return 1 } @@ -99,6 +99,9 @@ out_file="" data="" config="" auth_mode="none" +has_max_filesize=0 +has_max_time=0 +has_connect_timeout=0 while [[ $# -gt 0 ]]; do case "$1" in -o) @@ -125,6 +128,18 @@ while [[ $# -gt 0 ]]; do fi shift 2 ;; + --max-filesize) + has_max_filesize=1 + shift 2 + ;; + --max-time) + has_max_time=1 + shift 2 + ;; + --connect-timeout) + has_connect_timeout=1 + shift 2 + ;; -H|--header|-u|--user) if [[ "$2" == *"fixture-token"* ]]; then : > "${MOSAIC_TEST_TOKEN_ARGV_MARKER:?}" @@ -152,6 +167,7 @@ elif [[ "$config" == *"user = \"fixture-user:fixture-password\""* ]]; then : > "${MOSAIC_TEST_BASIC_CONFIG_MARKER:?}" fi printf '%s %s %s\n' "$method" "$auth_mode" "$url" >> "${MOSAIC_TEST_CURL_LOG:?}" +printf '%s:%s:%s\n' "$has_max_filesize" "$has_max_time" "$has_connect_timeout" >> "${MOSAIC_TEST_CURL_BOUNDS_LOG:?}" case "$url" in */pulls/42) @@ -174,7 +190,11 @@ case "$url" in */pulls/42/commits*) case "${MOSAIC_TEST_COMMITS_MODE:?}" in verified) - body='[{"sha":"2222222222222222222222222222222222222222","commit":{"author":{"name":"Alice","email":"alice+branch@example.test"}},"author":{"login":"alice"}},{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"poster@example.test"}},"author":{"login":"poster"}}]' + if [[ "${MOSAIC_TEST_EMAIL_MODE:-safe}" == "escape" ]]; then + body='[{"sha":"2222222222222222222222222222222222222222","commit":{"author":{"name":"Alice","email":"alice+\u001b[31m@example.test"}},"author":{"login":"alice"}},{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"poster@example.test"}},"author":{"login":"poster"}}]' + else + body='[{"sha":"2222222222222222222222222222222222222222","commit":{"author":{"name":"Alice","email":"alice+branch@example.test"}},"author":{"login":"alice"}},{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"poster@example.test"}},"author":{"login":"poster"}}]' + fi ;; null-login) body='[{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"poster@example.test"}},"author":{"login":"poster"}},{"sha":"3333333333333333333333333333333333333333","commit":{"author":{"name":"Unresolved Author","email":"unresolved@example.test\n\u001b[31m"}},"author":null}]' @@ -228,6 +248,10 @@ else printf '%s' "$body" fi printf '%s' "$code" +case "${MOSAIC_TEST_CURL_FAILURE:-none}" in + oversize) exit 63 ;; + stalled) exit 28 ;; +esac SH chmod +x "$case_dir/detect-platform.sh" "$case_dir/pr-metadata.sh" \ @@ -240,6 +264,7 @@ run_case() { shift 2 MOSAIC_TEST_COMMITS_MODE="$mode" \ MOSAIC_TEST_CURL_LOG="$case_dir/curl.log" \ + MOSAIC_TEST_CURL_BOUNDS_LOG="$case_dir/curl-bounds.log" \ MOSAIC_TEST_MERGE_PAYLOAD="$case_dir/merge-payload.json" \ MOSAIC_TEST_TOKEN_ARGV_MARKER="$case_dir/token-in-argv" \ MOSAIC_TEST_BASIC_ARGV_MARKER="$case_dir/basic-in-argv" \ @@ -285,6 +310,30 @@ fi if grep -q '/users/' "$verified_dir/curl.log" 2>/dev/null; then fail "verified path performed a forbidden second /users lookup" fi +if grep -qv '^1:1:1$' "$verified_dir/curl-bounds.log"; then + fail "verified path did not apply size/max-time/connect-time bounds to every provider download" +fi + +# A linked email containing a terminal escape must block before mutation. +escape_email_dir=$(make_case escape-email) +set +e +escape_email_output=$(MOSAIC_TEST_EMAIL_MODE=escape run_case "$escape_email_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1) +escape_email_rc=$? +set -e +[[ "$escape_email_rc" -ne 0 ]] || fail "control-byte email unexpectedly passed" +[[ "$escape_email_output" == *"unusable linked identity"* ]] || fail "control-byte email refusal lost its diagnostic" +[[ ! -e "$escape_email_dir/merge-payload.json" ]] || fail "control-byte email reached the merge API" + +# Curl transfer and duration failures must remain failures even with HTTP 200. +for failure_mode in oversize stalled; do + failure_dir=$(make_case "curl-$failure_mode") + set +e + failure_output=$(MOSAIC_TEST_CURL_FAILURE="$failure_mode" run_case "$failure_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1) + failure_rc=$? + set -e + [[ "$failure_rc" -ne 0 ]] || fail "curl $failure_mode failure was discarded: $failure_output" + [[ ! -e "$failure_dir/merge-payload.json" ]] || fail "curl $failure_mode failure reached the merge API" +done # The authenticated head is re-read under the mutation credential but cannot # replace the canonical preflight/review head. A move blocks before enumeration @@ -315,39 +364,44 @@ set -e [[ ! -e "$token_missing_dir/basic-resolution.log" ]] || fail "missing token resolved Basic Auth after identity failure" [[ ! -e "$token_missing_dir/curl.log" ]] || fail "missing token reached a provider request" -# Token rejection during inspection must fall back to Basic Auth, then repeat -# BOTH inspection and merge with that one Basic credential handle. +# A failed Basic resolver must never use its nonempty output or reach mutation. +basic_rc_dir=$(make_case basic-resolver-rc) +set +e +basic_rc_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_BASIC_RC=91 MOSAIC_TEST_FALLBACK_MODE=inspection \ + run_case "$basic_rc_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1) +basic_rc_rc=$? +set -e +[[ "$basic_rc_rc" -ne 0 ]] || fail "failed Basic resolver output unexpectedly authorized a merge: $basic_rc_output" +[[ ! -e "$basic_rc_dir/merge-payload.json" ]] || fail "failed Basic resolver reached the merge API" + +# HTTP 401 never changes principals: inspection rejection fails closed without +# resolving or attempting Basic Auth. fallback_inspect_dir=$(make_case fallback-inspection) set +e fallback_inspect_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=inspection \ run_case "$fallback_inspect_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1) fallback_inspect_rc=$? set -e -[[ "$fallback_inspect_rc" -eq 0 ]] || fail "inspection fallback expected rc=0, got rc=$fallback_inspect_rc: $fallback_inspect_output" -[[ -s "$fallback_inspect_dir/merge-payload.json" ]] || fail "inspection fallback did not reach the merge API" -[[ -e "$fallback_inspect_dir/basic-via-config" ]] || fail "inspection fallback did not transport Basic Auth through stdin config" -[[ ! -e "$fallback_inspect_dir/basic-in-argv" ]] || fail "inspection fallback exposed Basic Auth in curl argv" -[[ ! -e "$fallback_inspect_dir/metadata-in-argv" ]] || fail "inspection fallback exposed PR metadata in child argv" -[[ "$(wc -l < "$fallback_inspect_dir/token-resolution.log")" -eq 1 ]] || fail "inspection fallback did not resolve token exactly once" -[[ "$(wc -l < "$fallback_inspect_dir/basic-resolution.log")" -eq 1 ]] || fail "inspection fallback did not resolve Basic Auth exactly once" +[[ "$fallback_inspect_rc" -ne 0 ]] || fail "inspection token rejection unexpectedly changed principals" +[[ "$fallback_inspect_output" == *"refusing cross-principal credential fallback"* ]] || fail "inspection token rejection lost its refusal diagnostic" +[[ ! -e "$fallback_inspect_dir/basic-resolution.log" ]] || fail "inspection token rejection resolved Basic Auth" +[[ ! -e "$fallback_inspect_dir/merge-payload.json" ]] || fail "inspection token rejection reached merge mutation" inspect_sequence=$(awk '{print $1 ":" $2}' "$fallback_inspect_dir/curl.log" | paste -sd, -) -[[ "$inspect_sequence" == "GET:token,GET:basic,GET:basic,POST:basic" ]] || fail "inspection fallback was not bound per credential (calls=$inspect_sequence)" +[[ "$inspect_sequence" == "GET:token" ]] || fail "inspection rejection made unexpected provider calls (calls=$inspect_sequence)" -# Token rejection at merge is a separate seam: Basic fallback must re-inspect -# instead of reusing evidence gathered under the rejected token. +# Token rejection at merge likewise fails closed without cross-principal retry. fallback_merge_dir=$(make_case fallback-merge) set +e fallback_merge_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=merge \ run_case "$fallback_merge_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1) fallback_merge_rc=$? set -e -[[ "$fallback_merge_rc" -eq 0 ]] || fail "merge fallback expected rc=0, got rc=$fallback_merge_rc: $fallback_merge_output" -[[ -s "$fallback_merge_dir/merge-payload.json" ]] || fail "merge fallback did not reach a successful merge API payload" -[[ -e "$fallback_merge_dir/basic-via-config" ]] || fail "merge fallback did not transport Basic Auth through stdin config" -[[ ! -e "$fallback_merge_dir/basic-in-argv" ]] || fail "merge fallback exposed Basic Auth in curl argv" -[[ ! -e "$fallback_merge_dir/metadata-in-argv" ]] || fail "merge fallback exposed PR metadata in child argv" +[[ "$fallback_merge_rc" -ne 0 ]] || fail "merge token rejection unexpectedly changed principals" +[[ "$fallback_merge_output" == *"refusing cross-principal credential fallback"* ]] || fail "merge token rejection lost its refusal diagnostic" +[[ ! -e "$fallback_merge_dir/basic-resolution.log" ]] || fail "merge token rejection resolved Basic Auth" +[[ ! -e "$fallback_merge_dir/merge-payload.json" ]] || fail "merge token rejection recorded a successful payload" merge_sequence=$(awk '{print $1 ":" $2}' "$fallback_merge_dir/curl.log" | paste -sd, -) -[[ "$merge_sequence" == "GET:token,GET:token,POST:token,GET:basic,GET:basic,POST:basic" ]] || fail "merge fallback reused cross-credential evidence (calls=$merge_sequence)" +[[ "$merge_sequence" == "GET:token,GET:token,POST:token" ]] || fail "merge rejection made unexpected provider calls (calls=$merge_sequence)" # BLOCK path: a commit email exists but author.login is null. It must name both # facts, name the escalation principal, and never reach the merge endpoint. @@ -405,6 +459,7 @@ set -e [[ "$error_output" == *"branch policy rejected"* ]] || fail "provider error response was discarded" [[ "$error_output" == *'\n\x1b[31m'* ]] || fail "provider error response did not escape control characters" [[ "$error_output" != *$'\033'* ]] || fail "provider error response emitted a raw terminal escape" +[[ "$error_output" != *"Basic Auth fallback"* ]] || fail "provider error advertised removed Basic Auth fallback" [[ ! -e "$error_dir/basic-resolution.log" ]] || fail "HTTP 409 policy denial incorrectly triggered Basic Auth fallback" # Authorization denials likewise fail closed instead of changing principals. @@ -416,6 +471,7 @@ forbidden_rc=$? set -e [[ "$forbidden_rc" -ne 0 ]] || fail "HTTP 403 authorization denial unexpectedly passed" [[ "$forbidden_output" == *"HTTP 403"* ]] || fail "authorization denial omitted the HTTP status" +[[ "$forbidden_output" != *"Basic Auth fallback"* ]] || fail "authorization denial advertised removed Basic Auth fallback" [[ ! -e "$forbidden_dir/basic-resolution.log" ]] || fail "HTTP 403 authorization denial incorrectly triggered Basic Auth fallback" # The BLOCK destination cannot be generic or inferred after failure: opting in