fix(git-tools): env-robust token parse, host-bound creds, real Gitea URL verify (#865 round-5)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
CI-red root cause (classification a: my round-4 change fails in the clean/cold CI env): get_gitea_token_for_login hard-required PyYAML (`import yaml`), which is absent on CI's node:24-alpine (python3 without py3-yaml). Round-4's --login override cases were the first to exercise that path, turning the mosaic package test (test:framework-shell -> test-pr-review-gitea-comment.sh) RED. Fix: add an indentation-aware line-parser fallback that resolves the SAME per-name token PyYAML would from tea's flat `logins:` list; PyYAML stays the fast path. This also repairs a latent production defect (--login overrides were silently unusable on any PyYAML-less host). Auditor blockers folded into the same round-5: 1. issue_url vs pull_request_url shape (correctness): Gitea populates WEB (html) URLs in issue_url/pull_request_url, not API paths, and a PR-conversation comment carries pull_request_url (issue_url empty). Verification now accepts either web shape scoped to the repo slug + number, so a durable write is never rejected for URL shape. Test stubs now emit the REAL Gitea web shapes. 2. Cross-host credential binding (security): get_gitea_token_for_login now takes the repo host and requires the matched login's configured URL host to equal it; an override login configured for a different host FAILS CLOSED instead of sending a cross-host credential. Regression tests added to both suites. 3. Non-exhaustive enumeration (false-fail): removed the redundant, non-exhaustive post-verification list enumeration (gitea_fetch_all + confirm_*_enumerable) from both wrappers; the exact-id GET is authoritative. Pagination cases dropped; a guard asserts no list enumeration is performed. 4. Trap clobbering / temp-file leak (security/hygiene): removing the nested enumeration eliminates the RETURN-trap nesting that clobbered caller cleanup; remaining RETURN traps are single/non-nested and clean up on all exit paths. Temp-file leak regression tests (success + failure paths) added to both suites. 5. README: corrected the exhaustive-pagination claim and documented host-bound --login selection. Preserves every round-2/3/4 fix (explicit --login fail-closed at all write sites, token->identity attribution seam). Gates: cold `pnpm turbo run test --filter=@mosaicstack/mosaic` green (14/14); full test-*.sh suite green with AND without PyYAML; bash -n, shellcheck -x -S warning, prettier --check README clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -26,15 +26,22 @@
|
||||
# concurrency window — no fallback list scan can rescue a no-op);
|
||||
# 5. fails closed when the created record is not authored by the acting
|
||||
# identity;
|
||||
# 6. enumerates the created id in the issue's FULLY PAGINATED comment list,
|
||||
# finding it even when it lands beyond page 1;
|
||||
# 6. treats the exact-id GET as the SOLE authority — it performs NO follow-up
|
||||
# list enumeration (the stub exposes no comment-list endpoint, so any
|
||||
# residual enumeration attempt would fail the run);
|
||||
# 7. with a RESOLVABLE --login override, performs the write, the /user identity
|
||||
# lookup, and the read-back ALL under THAT login's token/identity — never
|
||||
# the host default;
|
||||
# 8. with an UNRESOLVABLE --login override, FAILS CLOSED (nonzero, no write, no
|
||||
# success line) instead of silently downgrading to the host default
|
||||
# identity — the token seam maps each bearer token to the identity it
|
||||
# authenticates as, so a misattributed write is caught.
|
||||
# authenticates as, so a misattributed write is caught;
|
||||
# 9. with a --login override whose tea config URL is a DIFFERENT host than the
|
||||
# repo remote, FAILS CLOSED (host-bound token selection) rather than sending
|
||||
# that other host's credential cross-host;
|
||||
# 10. leaves NO temp files behind (POST/GET bodies + metadata) on either the
|
||||
# success or the failure path — nested function-scoped RETURN traps do not
|
||||
# clobber each other and every scratch file is removed on all exit paths.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -49,13 +56,16 @@ AUTH_LOG="$WORK_DIR/auth.log"
|
||||
OUTPUT_FILE="$WORK_DIR/output.log"
|
||||
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||
STATE_FILE="$WORK_DIR/comments.json"
|
||||
# A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak
|
||||
# check can assert every POST/GET body + metadata temp file is cleaned up.
|
||||
TMP_SCRATCH="$WORK_DIR/scratch"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR"
|
||||
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH"
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
|
||||
@@ -71,13 +81,22 @@ FOREIGN_LOGIN="other-writer"
|
||||
OVERRIDE_LOGIN="delegated-reviewer"
|
||||
DEFAULT_TOKEN="test-only-placeholder"
|
||||
OVERRIDE_TOKEN="override-token-placeholder"
|
||||
# A --login override whose tea config URL points at a DIFFERENT Gitea host than
|
||||
# the repo remote (git.mosaicstack.dev). Its token must NEVER be sent to the
|
||||
# repo host: host-bound selection must fail closed on the host mismatch.
|
||||
CROSS_HOST_LOGIN="foreign-host-reviewer"
|
||||
CROSS_HOST_TOKEN="cross-host-token-placeholder"
|
||||
|
||||
# tea config: the override login has its own token here (as tea itself stores
|
||||
# per-login tokens). The default login name ("mosaicstack") is deliberately NOT
|
||||
# present, so the no-override default path resolves via the host credential
|
||||
# fallback while an explicit --login must resolve from this file or fail closed.
|
||||
# A second login is configured for a DIFFERENT host to exercise host-bound
|
||||
# rejection.
|
||||
mkdir -p "$XDG_DIR/tea"
|
||||
OVERRIDE_LOGIN="$OVERRIDE_LOGIN" OVERRIDE_TOKEN="$OVERRIDE_TOKEN" python3 - "$XDG_DIR/tea/config.yml" <<'PY'
|
||||
OVERRIDE_LOGIN="$OVERRIDE_LOGIN" OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
|
||||
CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
|
||||
python3 - "$XDG_DIR/tea/config.yml" <<'PY'
|
||||
import os
|
||||
import sys
|
||||
|
||||
@@ -86,6 +105,9 @@ with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
||||
handle.write(f" - name: {os.environ['OVERRIDE_LOGIN']}\n")
|
||||
handle.write(" url: https://git.mosaicstack.dev\n")
|
||||
handle.write(f" token: {os.environ['OVERRIDE_TOKEN']}\n")
|
||||
handle.write(f" - name: {os.environ['CROSS_HOST_LOGIN']}\n")
|
||||
handle.write(" url: https://git.uscllc.com\n")
|
||||
handle.write(f" token: {os.environ['CROSS_HOST_TOKEN']}\n")
|
||||
PY
|
||||
|
||||
CONFIGURED_GITEA_URL="https://git.mosaicstack.dev" python3 - "$CREDENTIALS_FILE" <<'PY'
|
||||
@@ -123,10 +145,12 @@ SH
|
||||
chmod +x "$BIN_DIR/tea"
|
||||
|
||||
# curl stub: a small REST server backed by persistent on-disk comment state.
|
||||
# GET /user -> acting identity
|
||||
# POST /issues/7/comments -> CREATE + PERSIST, return created object
|
||||
# GET /issues/comments/{id} -> read the persisted record by exact id
|
||||
# GET /issues/7/comments?page=&.. -> paginated listing of persisted state
|
||||
# GET /user -> acting identity
|
||||
# POST /issues/7/comments -> CREATE + PERSIST, return created object
|
||||
# GET /issues/comments/{id} -> read the persisted record by exact id
|
||||
# There is deliberately NO comment-LIST endpoint: exact-id read-back is the sole
|
||||
# authority, so any residual list enumeration attempt hits the unexpected-request
|
||||
# guard and fails the test.
|
||||
cat > "$BIN_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
@@ -164,6 +188,7 @@ acting_identity=""
|
||||
case "$auth_token" in
|
||||
"$ISSUE_COMMENT_DEFAULT_TOKEN") acting_identity="$ISSUE_COMMENT_ACTING_LOGIN" ;;
|
||||
"$ISSUE_COMMENT_OVERRIDE_TOKEN") acting_identity="$ISSUE_COMMENT_OVERRIDE_LOGIN" ;;
|
||||
"$ISSUE_COMMENT_CROSS_HOST_TOKEN") acting_identity="$ISSUE_COMMENT_CROSS_HOST_LOGIN" ;;
|
||||
esac
|
||||
printf '%s %s %s\n' "$method" "$path" "${acting_identity:-<unauthenticated>}" >> "$ISSUE_COMMENT_AUTH_LOG"
|
||||
|
||||
@@ -211,7 +236,10 @@ record = {
|
||||
"id": new_id,
|
||||
"body": body,
|
||||
"user": {"login": author},
|
||||
"issue_url": f"https://git.mosaicstack.dev/api/v1/repos/{repo}/issues/7",
|
||||
# REAL Gitea comment shape: issue_url is the WEB (html) path, not an API
|
||||
# path, and a plain issue comment leaves pull_request_url empty.
|
||||
"issue_url": f"https://git.mosaicstack.dev/{repo}/issues/7",
|
||||
"pull_request_url": "",
|
||||
}
|
||||
comments.append(record)
|
||||
with open(state_path, "w", encoding="utf-8") as handle:
|
||||
@@ -238,24 +266,6 @@ else:
|
||||
print("200")
|
||||
print(json.dumps(match))
|
||||
PY
|
||||
)
|
||||
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
|
||||
elif [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_BASE/issues/7/comments" ]]; then
|
||||
result=$(ISSUE_COMMENT_QUERY="$query" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
state_path = os.environ["ISSUE_COMMENT_STATE"]
|
||||
params = parse_qs(os.environ["ISSUE_COMMENT_QUERY"])
|
||||
limit = int(params.get("limit", ["50"])[0])
|
||||
page = int(params.get("page", ["1"])[0])
|
||||
with open(state_path, encoding="utf-8") as handle:
|
||||
comments = json.load(handle)
|
||||
start = (page - 1) * limit
|
||||
print("200")
|
||||
print(json.dumps(comments[start:start + limit]))
|
||||
PY
|
||||
)
|
||||
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
|
||||
else
|
||||
@@ -279,23 +289,30 @@ mode = os.environ["ISSUE_COMMENT_SEED_MODE"]
|
||||
body = os.environ["ISSUE_COMMENT_SEED_BODY"]
|
||||
acting = os.environ["ISSUE_COMMENT_SEED_ACTING"]
|
||||
repo = os.environ["ISSUE_COMMENT_SEED_REPO"]
|
||||
issue_url = f"https://git.mosaicstack.dev/api/v1/repos/{repo}/issues/7"
|
||||
# REAL Gitea comment shape: issue_url is the WEB path, pull_request_url empty.
|
||||
issue_url = f"https://git.mosaicstack.dev/{repo}/issues/7"
|
||||
|
||||
|
||||
def comment(cid, text, author):
|
||||
return {
|
||||
"id": cid,
|
||||
"body": text,
|
||||
"user": {"login": author},
|
||||
"issue_url": issue_url,
|
||||
"pull_request_url": "",
|
||||
}
|
||||
|
||||
|
||||
if mode == "fresh-success":
|
||||
# 50 pre-existing comments fill page 1 (limit 50); the comment this run
|
||||
# creates becomes id 51 and lands ALONE on page 2, exercising >page-1
|
||||
# pagination in the enumeration check.
|
||||
comments = [
|
||||
{"id": i, "body": f"prior {i}", "user": {"login": acting}, "issue_url": issue_url}
|
||||
for i in range(1, 51)
|
||||
]
|
||||
# 50 pre-existing comments already exist; the comment this run creates
|
||||
# becomes id 51, proving exact-id read-back works regardless of how many
|
||||
# comments precede it (no list enumeration is involved).
|
||||
comments = [comment(i, f"prior {i}", acting) for i in range(1, 51)]
|
||||
elif mode == "no-op-concurrent":
|
||||
# A concurrent SAME-IDENTITY comment with the IDENTICAL body already exists.
|
||||
# The wrapper's own write will be a no-op; it must still fail closed because
|
||||
# no created id is returned — it must not scan and accept this record.
|
||||
comments = [
|
||||
{"id": 55, "body": body, "user": {"login": acting}, "issue_url": issue_url}
|
||||
]
|
||||
comments = [comment(55, body, acting)]
|
||||
else: # author-mismatch
|
||||
comments = []
|
||||
|
||||
@@ -315,6 +332,7 @@ run_comment() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
TMPDIR="$TMP_SCRATCH" \
|
||||
XDG_CONFIG_HOME="$XDG_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
|
||||
@@ -325,8 +343,10 @@ run_comment() {
|
||||
ISSUE_COMMENT_ACTING_LOGIN="$ACTING_LOGIN" \
|
||||
ISSUE_COMMENT_FOREIGN_LOGIN="$FOREIGN_LOGIN" \
|
||||
ISSUE_COMMENT_OVERRIDE_LOGIN="$OVERRIDE_LOGIN" \
|
||||
ISSUE_COMMENT_CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" \
|
||||
ISSUE_COMMENT_DEFAULT_TOKEN="$DEFAULT_TOKEN" \
|
||||
ISSUE_COMMENT_OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
|
||||
ISSUE_COMMENT_CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
|
||||
ISSUE_COMMENT_REPO_SLUG="$REPO_SLUG" \
|
||||
ISSUE_COMMENT_API_BASE="$API_BASE" \
|
||||
ISSUE_COMMENT_API_ROOT="$API_ROOT" \
|
||||
@@ -334,8 +354,21 @@ run_comment() {
|
||||
) > "$OUTPUT_FILE" 2>&1
|
||||
}
|
||||
|
||||
# Assert the wrapper left no scratch temp files behind in TMPDIR (POST/GET
|
||||
# request bodies + metadata). Called after both success and failure paths so a
|
||||
# clobbered/leaked RETURN trap is caught on every exit route.
|
||||
assert_no_temp_leak() {
|
||||
local context="$1" leaked
|
||||
leaked=$(find "$TMP_SCRATCH" -type f -name 'mosaic-issue-comment-*' 2>/dev/null || true)
|
||||
if [[ -n "$leaked" ]]; then
|
||||
echo "FAIL: issue-comment temp files leaked ($context):" >&2
|
||||
printf '%s\n' "$leaked" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Case 1: a genuine REST create (id 51) is verified end to end via its exact
|
||||
# provider-returned id and enumerated on page 2 of the paginated listing.
|
||||
# provider-returned id — no list enumeration is involved.
|
||||
run_comment fresh-success
|
||||
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 51)' "$OUTPUT_FILE"
|
||||
# The write is a REST POST, never a tea comment.
|
||||
@@ -348,12 +381,17 @@ fi
|
||||
grep -q "^GET $API_BASE/issues/comments/51$" "$CURL_LOG"
|
||||
# Acting identity resolved via GET /user.
|
||||
grep -q "^GET $API_ROOT/user$" "$CURL_LOG"
|
||||
# Enumeration paginated beyond page 1 to find the created comment.
|
||||
grep -q "^GET $API_BASE/issues/7/comments?limit=[0-9]*&page=2$" "$CURL_LOG"
|
||||
# No comment-list enumeration is performed — the exact-id GET is authoritative.
|
||||
if grep -Eq "^GET $API_BASE/issues/7/comments(\?|$)" "$CURL_LOG"; then
|
||||
echo "FAIL: wrapper performed a redundant comment-list enumeration" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Default path (no --login): the host credential fallback resolves, and the
|
||||
# write is performed AND self-verified under the host-default acting identity.
|
||||
grep -q "^POST $API_BASE/issues/7/comments $ACTING_LOGIN$" "$AUTH_LOG"
|
||||
grep -q "^GET $API_BASE/issues/comments/51 $ACTING_LOGIN$" "$AUTH_LOG"
|
||||
# Success path leaves no scratch temp files behind.
|
||||
assert_no_temp_leak "fresh-success"
|
||||
|
||||
# Case 2: a no-op write with a concurrent SAME-IDENTITY, same-body comment
|
||||
# already present must FAIL CLOSED — the closed concurrency window.
|
||||
@@ -382,6 +420,9 @@ if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
||||
echo "FAIL: read-back did not enforce acting-identity authorship" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Failure-after-read-back path must ALSO leave no scratch temp files behind
|
||||
# (proves the RETURN traps clean up on the error-return route, not just success).
|
||||
assert_no_temp_leak "author-mismatch"
|
||||
|
||||
# Case 4: a RESOLVABLE --login override — the write, the /user identity lookup,
|
||||
# and the read-back must ALL be performed under THAT login's token/identity, not
|
||||
@@ -420,4 +461,34 @@ if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Case 6: a --login override that IS present in tea config but whose URL is a
|
||||
# DIFFERENT host than the repo remote must FAIL CLOSED (host-bound selection).
|
||||
# The cross-host token must NEVER be sent to the repo host, and no write occurs.
|
||||
if run_comment cross-host --login "$CROSS_HOST_LOGIN"; then
|
||||
echo "FAIL: cross-host --login override did not fail closed" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
||||
echo "FAIL: cross-host --login override reported success" >&2
|
||||
exit 1
|
||||
fi
|
||||
# The cross-host credential must not have performed ANY request against the repo
|
||||
# host — no request may be attributed to the cross-host identity.
|
||||
if grep -q " $CROSS_HOST_LOGIN\$" "$AUTH_LOG"; then
|
||||
echo "FAIL: cross-host credential was sent to the repo host (cross-host leak)" >&2
|
||||
cat "$AUTH_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q "^POST $API_BASE/issues/7/comments" "$CURL_LOG"; then
|
||||
echo "FAIL: cross-host --login override still performed a write" >&2
|
||||
exit 1
|
||||
fi
|
||||
# It must not have silently downgraded to the host default identity either.
|
||||
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
|
||||
echo "FAIL: cross-host --login override fell back to the host default identity" >&2
|
||||
exit 1
|
||||
fi
|
||||
assert_no_temp_leak "cross-host"
|
||||
|
||||
echo "issue-comment.sh REST create + exact-id read-back regression passed"
|
||||
|
||||
Reference in New Issue
Block a user