fix(git-tools): scope-aware YAML fallback, port-bound creds, origin-pinned URL + review-body verification (#865)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Round-6 remediation for PR #866 addressing four cross-host exact-diff audit blockers (REQUEST_CHANGES governs): Blocker 1 (detect-platform.sh get_gitea_token_for_login line parser): the PyYAML-absence fallback attributed any `key: value` at any depth to the current login, so a token from a nested sub-map or a mis-indented line could be selected where PyYAML fails closed, and inline comments were not stripped. The fallback is now scope-aware — a field attaches only at the entry's own direct-field indentation, only list items at the login list's own dash indent open an entry — and _strip_scalar strips a trailing inline comment like PyYAML. It is therefore only ever MORE conservative than PyYAML, never less. Blocker 2 (detect-platform.sh host bind): credential binding compared parsed.hostname only, dropping the port, so a :9443 login satisfied a portless host and a matching :8443 login was rejected. Binding now normalizes scheme + host + effective port (scheme default applied symmetrically) exactly like gitea_url_matches_host. Blocker 3 (issue-comment.sh + pr-review.sh read-back URL check): verification used path.endswith, accepting a look-alike host or a decoy path prefix. It now pins the returned issue_url/pull_request_url ORIGIN (scheme+host+effective-port) and FULL path (deployment prefix + exact owner/repo + kind + number). A new GITEA_WEB_BASE is exported from gitea_resolve_api_for_login for this. Blocker 4 (pr-review.sh gitea_submit_review_verified): the submitted review body was not verified, so a finalized/reused pending review id carrying foreign Content passed. The persisted body is now bound to the exact submitted body. Tests: added forced-PyYAML-absence parser-equivalence fixtures (nested sub-map, sibling, mis-indent, inline comment, tab-indent fail-closed, port match/mismatch) to test-gitea-login-resolution.sh; URL-forgery fail-closed cases (wrong-host/owner/repo + prefix injection) to both write suites; and a reused-review-id body-mismatch case to the pr-review suite. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -233,6 +233,27 @@ if mode == "no-op-concurrent-review":
|
||||
print(json.dumps({}))
|
||||
raise SystemExit(0)
|
||||
|
||||
# review-body-reuse (#865 Blocker 4): Gitea v1.25.4's SubmitReview can finalize
|
||||
# and REUSE a pending review id whose Content was authored earlier — NOT this
|
||||
# submit's body. id/author/state/head all line up with the request; only the
|
||||
# persisted body diverges, so only body verification catches it. The read-back
|
||||
# GET returns this same divergent-body record.
|
||||
if mode == "review-body-reuse":
|
||||
new_id = (max((r["id"] for r in reviews), default=0)) + 1
|
||||
record = {
|
||||
"id": new_id,
|
||||
"state": submitted.get("event"),
|
||||
"commit_id": submitted.get("commit_id"),
|
||||
"body": "leftover-pending-content-not-this-submit",
|
||||
"user": {"login": acting},
|
||||
}
|
||||
reviews.append(record)
|
||||
with open(state_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(reviews, handle)
|
||||
print("201")
|
||||
print(json.dumps(record))
|
||||
raise SystemExit(0)
|
||||
|
||||
author = foreign if mode == "author-mismatch-review" else acting
|
||||
new_id = (max((r["id"] for r in reviews), default=0)) + 1
|
||||
record = {
|
||||
@@ -277,24 +298,42 @@ elif [[ "$method" == "POST" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE/issues/1
|
||||
write_response 500 '{"message":"simulated rejection"}'
|
||||
;;
|
||||
*)
|
||||
emit "$(PR_REVIEW_PAYLOAD="$payload" python3 - <<'PY'
|
||||
emit "$(PR_REVIEW_PAYLOAD="$payload" PR_REVIEW_TEST_MODE="$mode" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
state_path = os.environ["PR_REVIEW_COMMENTS"]
|
||||
acting = os.environ["PR_REVIEW_ACTING_LOGIN"]
|
||||
web_base = os.environ["PR_REVIEW_WEB_BASE"]
|
||||
mode = os.environ.get("PR_REVIEW_TEST_MODE", "")
|
||||
body = json.loads(os.environ["PR_REVIEW_PAYLOAD"]).get("body")
|
||||
# REAL Gitea shape for a comment posted to a PR's conversation
|
||||
# (/issues/{n}/comments on a PR): pull_request_url is the WEB pulls path and
|
||||
# issue_url is left empty. This is what the wrapper must tolerate — it must NOT
|
||||
# require an API-shaped issue_url.
|
||||
pr_url = f"{web_base}/pulls/123"
|
||||
# URL-injection modes (#865 Blocker 3): id/author/body are all correct but the
|
||||
# provider-returned pull_request_url is forged, so ONLY origin+full-path
|
||||
# verification can catch them.
|
||||
_p = urlparse(web_base)
|
||||
_origin = f"{_p.scheme}://{_p.netloc}"
|
||||
_slug = _p.path # /<owner>/<repo>
|
||||
if mode == "comment-url-wrong-host":
|
||||
pr_url = f"https://evil.example{_slug}/pulls/123"
|
||||
elif mode == "comment-url-wrong-owner":
|
||||
pr_url = f"{_origin}/attacker/stack/pulls/123"
|
||||
elif mode == "comment-url-wrong-repo":
|
||||
pr_url = f"{_origin}/mosaicstack/other/pulls/123"
|
||||
elif mode == "comment-url-suffix-injection":
|
||||
# Prefix-injected: a bare endswith("/<slug>/pulls/123") test would ACCEPT it.
|
||||
pr_url = f"{_origin}/deceptive{_slug}/pulls/123"
|
||||
record = {
|
||||
"id": 456,
|
||||
"body": body,
|
||||
"user": {"login": acting},
|
||||
"issue_url": "",
|
||||
"pull_request_url": f"{web_base}/pulls/123",
|
||||
"pull_request_url": pr_url,
|
||||
}
|
||||
with open(state_path, "w", encoding="utf-8") as handle:
|
||||
json.dump([record], handle)
|
||||
@@ -693,4 +732,37 @@ if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
|
||||
fi
|
||||
assert_no_temp_leak "cross-host"
|
||||
|
||||
# Case 11 (#865 Blocker 4): SubmitReview finalizes/reuses a pending review id
|
||||
# whose persisted body is NOT this submit's body. id/author/state/head all match
|
||||
# the request, so ONLY body verification can catch the divergence — it must FAIL
|
||||
# CLOSED. (Submit a non-empty body so the mismatch is meaningful.)
|
||||
if run_review review-body-reuse approve real-submitted-review-body; then
|
||||
echo "FAIL: review with a reused/foreign body was accepted (body not verified)" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
|
||||
echo "FAIL: read-back did not enforce the submitted review body" >&2
|
||||
exit 1
|
||||
fi
|
||||
assert_no_temp_leak "review-body-reuse"
|
||||
|
||||
# Cases 12-15 (#865 Blocker 3): a PR comment whose id/author/body are all correct
|
||||
# but whose provider-returned pull_request_url is forged must FAIL CLOSED.
|
||||
# Verification pins the URL's ORIGIN (scheme+host+effective-port) and FULL path
|
||||
# (deployment prefix + exact owner/repo + kind + number); a bare endswith/suffix
|
||||
# test would wrongly accept the look-alike-host and prefix-injection variants.
|
||||
for bad_mode in comment-url-wrong-host comment-url-wrong-owner comment-url-wrong-repo comment-url-suffix-injection; do
|
||||
if run_review "$bad_mode" comment durable-body; then
|
||||
echo "FAIL: forged comment URL ($bad_mode) was accepted" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
||||
echo "FAIL: forged comment URL ($bad_mode) passed verification" >&2
|
||||
exit 1
|
||||
fi
|
||||
assert_no_temp_leak "$bad_mode"
|
||||
done
|
||||
|
||||
echo "pr-review.sh REST review + comment create/read-back regression passed"
|
||||
|
||||
Reference in New Issue
Block a user