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:
@@ -118,6 +118,11 @@ gitea_resolve_api_for_login() {
|
||||
}
|
||||
GITEA_API_ROOT="${configured_url%/}/api/v1"
|
||||
GITEA_API_BASE="$GITEA_API_ROOT/repos/$repo"
|
||||
# The provider WEB base (scheme + host + effective port + any deployment path
|
||||
# prefix) that Gitea uses to build a comment's html issue_url/pull_request_url.
|
||||
# Read-back verification pins the returned URL's origin + path prefix to THIS,
|
||||
# not just a repo/issue suffix.
|
||||
GITEA_WEB_BASE="${configured_url%/}"
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -230,13 +235,26 @@ PY
|
||||
|
||||
EXPECTED_COMMENT_ID="$created_id" EXPECTED_COMMENT_BODY="$comment_body" \
|
||||
ACTING_LOGIN="$acting_login" EXPECTED_REPO_SLUG="${GITEA_API_BASE##*/repos/}" \
|
||||
EXPECTED_NUMBER="$issue_number" \
|
||||
EXPECTED_NUMBER="$issue_number" EXPECTED_WEB_BASE="$GITEA_WEB_BASE" \
|
||||
python3 - "$readback_file" <<'PY' || return 1
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _origin_and_path(url):
|
||||
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
|
||||
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
|
||||
# port and its explicit default form compare equal.
|
||||
parsed = urlparse(url or "")
|
||||
scheme = (parsed.scheme or "").lower()
|
||||
host = (parsed.hostname or "").lower()
|
||||
default_port = 80 if scheme == "http" else 443
|
||||
port = parsed.port if parsed.port is not None else default_port
|
||||
return (scheme, host, port), parsed.path.rstrip("/")
|
||||
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as response:
|
||||
comment = json.load(response)
|
||||
@@ -247,24 +265,36 @@ try:
|
||||
acting_login = os.environ["ACTING_LOGIN"]
|
||||
slug = os.environ["EXPECTED_REPO_SLUG"]
|
||||
number = os.environ["EXPECTED_NUMBER"]
|
||||
web_base = os.environ["EXPECTED_WEB_BASE"]
|
||||
# Gitea populates WEB (html) URLs here, not API paths. A plain issue comment
|
||||
# carries issue_url = <app>/<owner>/<repo>/issues/<n> (pull_request_url
|
||||
# carries issue_url = <web_base>/<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("/")
|
||||
# pull_request_url = <web_base>/<owner>/<repo>/pulls/<n> (issue_url empty).
|
||||
# Pin the returned URL's ORIGIN (scheme+host+port) and its FULL path to this
|
||||
# provider + repo + kind + number — an endswith/suffix test would accept a
|
||||
# look-alike host (evil.example/deceptive/<slug>/issues/N) or a same-host
|
||||
# decoy prefix (/other/<slug>/issues/N), so compare the whole thing.
|
||||
base_origin, base_path = _origin_and_path(web_base)
|
||||
expected_issue_path = f"{base_path}/{slug}/issues/{number}"
|
||||
expected_pr_path = f"{base_path}/{slug}/pulls/{number}"
|
||||
|
||||
def _belongs(url, expected_path):
|
||||
if not url:
|
||||
return False
|
||||
origin, path = _origin_and_path(url)
|
||||
return origin == base_origin and path == expected_path
|
||||
|
||||
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(issue_suffix) or pr_path.endswith(pr_suffix)):
|
||||
raise ValueError("created comment does not belong to this issue")
|
||||
if not (
|
||||
_belongs(comment.get("issue_url"), expected_issue_path)
|
||||
or _belongs(comment.get("pull_request_url"), expected_pr_path)
|
||||
):
|
||||
raise ValueError("created comment does not belong to this issue on this provider/repo")
|
||||
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
|
||||
print(f"Error: Gitea comment persistence verification failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
Reference in New Issue
Block a user