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:
@@ -97,12 +97,12 @@ gitea_resolve_api_for_login() {
|
||||
|
||||
host=$(get_remote_host)
|
||||
if [[ -n "$override_explicit" ]]; then
|
||||
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login") || {
|
||||
echo "Error: could not resolve a Gitea token for --login '$effective_login'; refusing to fall back to the host default identity (comment write/read-back)" >&2
|
||||
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
|
||||
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (comment write/read-back)" >&2
|
||||
return 1
|
||||
}
|
||||
else
|
||||
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login") \
|
||||
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") \
|
||||
|| GITEA_API_TOKEN=$(get_gitea_token "$host") || {
|
||||
echo "Error: Gitea token not found for login '$effective_login' (comment write/read-back)" >&2
|
||||
return 1
|
||||
@@ -121,63 +121,6 @@ gitea_resolve_api_for_login() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# Fetch every page of a Gitea list endpoint into $2 (merged into one JSON
|
||||
# array). Gitea paginates list responses, so a single-page read would
|
||||
# false-negative once a newly created record lands beyond page 1. Walks
|
||||
# page=1,2,… until a short page (fewer than the requested limit) or an empty
|
||||
# page is returned, so the merged array is exhaustive. $1 is the endpoint URL
|
||||
# with NO query string. Returns non-zero (clear stderr) on any transport /
|
||||
# HTTP / parse failure.
|
||||
gitea_fetch_all() {
|
||||
local base_url="$1" dest="$2" page=1 limit=50 status page_file count
|
||||
|
||||
printf '[]' > "$dest"
|
||||
while :; do
|
||||
page_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-page.XXXXXX")
|
||||
if ! status=$(curl -sS -o "$page_file" -w '%{http_code}' \
|
||||
-H "Authorization: token $GITEA_API_TOKEN" \
|
||||
"${base_url}?limit=${limit}&page=${page}"); then
|
||||
rm -f "$page_file"
|
||||
echo "Error: Gitea list read transport failed" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$status" != "200" ]]; then
|
||||
rm -f "$page_file"
|
||||
echo "Error: Gitea list read failed with HTTP $status" >&2
|
||||
return 1
|
||||
fi
|
||||
count=$(DEST="$dest" python3 - "$page_file" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open(os.environ["DEST"], encoding="utf-8") as merged_file:
|
||||
merged = json.load(merged_file)
|
||||
with open(sys.argv[1], encoding="utf-8") as page_file:
|
||||
page = json.load(page_file)
|
||||
if not isinstance(page, list):
|
||||
raise ValueError("page response is not a list")
|
||||
merged.extend(item for item in page if isinstance(item, dict))
|
||||
with open(os.environ["DEST"], "w", encoding="utf-8") as merged_file:
|
||||
json.dump(merged, merged_file)
|
||||
except (OSError, json.JSONDecodeError, TypeError, ValueError) as error:
|
||||
print(f"Error: could not merge Gitea list page: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(len(page))
|
||||
PY
|
||||
) || { rm -f "$page_file"; return 1; }
|
||||
rm -f "$page_file"
|
||||
[[ "$count" -lt "$limit" ]] && break
|
||||
page=$((page + 1))
|
||||
if [[ "$page" -gt 1000 ]]; then
|
||||
echo "Error: Gitea list pagination exceeded 1000 pages" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# Resolve the login of the identity the API token authenticates as (GET
|
||||
# /user). Used to attribute a read-back record to THIS invocation's writer so
|
||||
# a concurrent write from a DIFFERENT identity cannot satisfy verification.
|
||||
@@ -216,54 +159,6 @@ print(login)
|
||||
PY
|
||||
}
|
||||
|
||||
# Confirm that the comment CREATED by this invocation ($2 = its provider id) is
|
||||
# enumerable in the issue's full, paginated comment listing and is authored by
|
||||
# the acting identity. Gitea paginates list responses, so a comment created
|
||||
# beyond page 1 must still be found; walking every page also proves the created
|
||||
# id is durably indexed against THIS issue rather than merely retrievable by id.
|
||||
# Returns non-zero (clear stderr) if the exact created id is not present with a
|
||||
# matching author.
|
||||
gitea_confirm_comment_enumerable() {
|
||||
local issue_number="$1" created_id="$2" acting_login="$3" merged_file
|
||||
|
||||
merged_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-readback.XXXXXX")
|
||||
trap 'rm -f "$merged_file"' RETURN
|
||||
|
||||
gitea_fetch_all "$GITEA_API_BASE/issues/$issue_number/comments" "$merged_file" || return 1
|
||||
|
||||
CREATED_COMMENT_ID="$created_id" ACTING_LOGIN="$acting_login" \
|
||||
python3 - "$merged_file" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as response:
|
||||
comments = json.load(response)
|
||||
if not isinstance(comments, list):
|
||||
raise ValueError("response is not a comment list")
|
||||
created_id = int(os.environ["CREATED_COMMENT_ID"])
|
||||
acting_login = os.environ["ACTING_LOGIN"]
|
||||
match = next(
|
||||
(
|
||||
c for c in comments
|
||||
if isinstance(c, dict)
|
||||
and c.get("id") == created_id
|
||||
and (c.get("user") or {}).get("login") == acting_login
|
||||
),
|
||||
None,
|
||||
)
|
||||
if match is None:
|
||||
raise ValueError(
|
||||
f"created comment id {created_id} is not enumerable in the issue's "
|
||||
"paginated comment list under the acting identity"
|
||||
)
|
||||
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
|
||||
print(f"Error: Gitea comment enumeration check failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
# Post a comment to a Gitea issue via the supported REST API and verify it
|
||||
# durably against a PROVIDER-RETURNED created id — never trust an exit code
|
||||
# (#865 defect class: tea's non-existent `tea issue comment` no-ops yet exits
|
||||
@@ -335,7 +230,7 @@ PY
|
||||
|
||||
EXPECTED_COMMENT_ID="$created_id" EXPECTED_COMMENT_BODY="$comment_body" \
|
||||
ACTING_LOGIN="$acting_login" EXPECTED_REPO_SLUG="${GITEA_API_BASE##*/repos/}" \
|
||||
EXPECTED_ISSUE_NUMBER="$issue_number" \
|
||||
EXPECTED_NUMBER="$issue_number" \
|
||||
python3 - "$readback_file" <<'PY' || return 1
|
||||
import json
|
||||
import os
|
||||
@@ -350,26 +245,31 @@ try:
|
||||
expected_id = int(os.environ["EXPECTED_COMMENT_ID"])
|
||||
expected_body = os.environ["EXPECTED_COMMENT_BODY"]
|
||||
acting_login = os.environ["ACTING_LOGIN"]
|
||||
expected_suffix = (
|
||||
f"/repos/{os.environ['EXPECTED_REPO_SLUG']}"
|
||||
f"/issues/{os.environ['EXPECTED_ISSUE_NUMBER']}"
|
||||
)
|
||||
issue_path = urlparse(comment.get("issue_url", "")).path.rstrip("/")
|
||||
slug = os.environ["EXPECTED_REPO_SLUG"]
|
||||
number = os.environ["EXPECTED_NUMBER"]
|
||||
# Gitea populates WEB (html) URLs here, not API paths. A plain issue comment
|
||||
# carries issue_url = <app>/<owner>/<repo>/issues/<n> (pull_request_url
|
||||
# empty); a comment posted to a PR's conversation carries
|
||||
# pull_request_url = <app>/<owner>/<repo>/pulls/<n> (issue_url empty).
|
||||
# Accept whichever the provider populated — scoped to THIS repo slug and
|
||||
# number — so a genuine write is never rejected merely for URL shape.
|
||||
issue_suffix = f"/{slug}/issues/{number}"
|
||||
pr_suffix = f"/{slug}/pulls/{number}"
|
||||
issue_path = urlparse(comment.get("issue_url") or "").path.rstrip("/")
|
||||
pr_path = urlparse(comment.get("pull_request_url") or "").path.rstrip("/")
|
||||
if comment.get("id") != expected_id:
|
||||
raise ValueError("read-back id does not match the created id")
|
||||
if (comment.get("user") or {}).get("login") != acting_login:
|
||||
raise ValueError("created comment is not authored by the acting identity")
|
||||
if comment.get("body") != expected_body:
|
||||
raise ValueError("created comment body does not match")
|
||||
if not issue_path.endswith(expected_suffix):
|
||||
if not (issue_path.endswith(issue_suffix) or pr_path.endswith(pr_suffix)):
|
||||
raise ValueError("created comment does not belong to this issue")
|
||||
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
|
||||
print(f"Error: Gitea comment persistence verification failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
gitea_confirm_comment_enumerable "$issue_number" "$created_id" "$acting_login" || return 1
|
||||
|
||||
echo "$created_id"
|
||||
return 0
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user