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:
@@ -599,29 +599,61 @@ config_path = sys.argv[1]
|
||||
|
||||
|
||||
def _strip_scalar(value):
|
||||
# Resolve a YAML flow scalar the way PyYAML would for tea's simple scalars:
|
||||
# honor surrounding quotes and strip a trailing inline comment. A quoted
|
||||
# scalar keeps its literal contents (any '#' inside is data, not a comment);
|
||||
# an unquoted scalar ends at the first whitespace-preceded '#' (a YAML
|
||||
# comment must be preceded by whitespace or line start), so "abc#def" stays
|
||||
# literal while "abc # note" becomes "abc".
|
||||
value = value.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
||||
value = value[1:-1]
|
||||
return value
|
||||
if not value:
|
||||
return value
|
||||
if value[0] in ("'", '"'):
|
||||
quote = value[0]
|
||||
end = value.find(quote, 1)
|
||||
if end != -1:
|
||||
return value[1:end]
|
||||
# Unterminated quote: PyYAML would error; return best-effort remainder so
|
||||
# the (more conservative) caller still compares against the wanted name.
|
||||
return value[1:]
|
||||
for index, char in enumerate(value):
|
||||
if char == "#" and (index == 0 or value[index - 1] in (" ", "\t")):
|
||||
value = value[:index]
|
||||
break
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _host_of(url):
|
||||
def _url_matches_repo_host(url):
|
||||
# Mirror gitea_url_matches_host (detect-platform.sh): the login's recorded
|
||||
# URL must name the SAME host AND the SAME effective port as the repo remote
|
||||
# host, not merely the same hostname. A login configured for an explicit,
|
||||
# non-default provider port (e.g. :9443) must NOT satisfy a portless (default
|
||||
# -port) repo host, and a login on the matching port (e.g. :8443) must NOT be
|
||||
# rejected. Ports are normalized by applying the login URL's scheme default
|
||||
# (80 for http, else 443) to whichever side omits the port, symmetrically, so
|
||||
# an implicit port and its explicit default-port form compare equal.
|
||||
if not isinstance(url, str) or not url:
|
||||
return None
|
||||
parsed = urlparse(url if "//" in url else f"//{url}")
|
||||
host = parsed.hostname
|
||||
return host.lower() if host else None
|
||||
return False
|
||||
configured = urlparse(url if "//" in url else f"//{url}")
|
||||
remote = urlparse(f"//{repo_host}")
|
||||
configured_host = configured.hostname
|
||||
if not configured_host or configured_host.lower() != (remote.hostname or "").lower():
|
||||
return False
|
||||
default_port = 80 if configured.scheme == "http" else 443
|
||||
configured_port = configured.port if configured.port is not None else default_port
|
||||
remote_port = remote.port if remote.port is not None else default_port
|
||||
return configured_port == remote_port
|
||||
|
||||
|
||||
def _accept(token, url):
|
||||
# Enforce the host bind before surfacing a token. When a repo host is given,
|
||||
# the login's recorded URL host must match it exactly; a login with no
|
||||
# usable URL (or a mismatched one) is rejected (fail closed) so a cross-host
|
||||
# credential is never emitted.
|
||||
# the login's recorded URL host AND port must match it; a login with no
|
||||
# usable URL (or a mismatched host/port) is rejected (fail closed) so a
|
||||
# cross-host (or cross-port) credential is never emitted.
|
||||
if not isinstance(token, str) or not token:
|
||||
return None
|
||||
if repo_host:
|
||||
if _host_of(url) != repo_host:
|
||||
if not _url_matches_repo_host(url):
|
||||
return None
|
||||
return token
|
||||
|
||||
@@ -647,11 +679,17 @@ def _token_via_pyyaml():
|
||||
def _token_via_lines():
|
||||
# Conservative fallback for hosts without PyYAML. tea writes config.yml in a
|
||||
# fixed, flat shape (a `logins:` list of maps with scalar name/url/token
|
||||
# fields), so a small indentation-aware scan resolves the SAME token PyYAML
|
||||
# would. It only ever returns the `token` of the entry whose `name` EXACTLY
|
||||
# equals the requested login AND whose url host matches the repo host, so it
|
||||
# cannot misattribute to another identity or host; anything it cannot parse
|
||||
# yields None (fail closed).
|
||||
# fields). This scan is SCOPE-AWARE: a field is attributed to a login entry
|
||||
# ONLY when it sits at that entry's own direct-field indentation. A field
|
||||
# nested inside a deeper sub-map (e.g. `extra:\n token: X`) or a mis-indented
|
||||
# line is NEVER attached to the entry — exactly the cases where PyYAML resolves
|
||||
# the entry's own `token` to None (or errors) and thus fails closed. Likewise
|
||||
# only list items at the login list's own dash indent open a new entry, so a
|
||||
# nested list item cannot masquerade as a sibling login. It returns the
|
||||
# `token` of the entry whose `name` EXACTLY equals the requested login AND
|
||||
# whose url host/port matches the repo host; anything else yields None (fail
|
||||
# closed). This can only ever be MORE conservative than PyYAML (it never
|
||||
# selects a token where PyYAML would refuse), never less.
|
||||
with open(config_path, encoding="utf-8") as handle:
|
||||
lines = handle.read().splitlines()
|
||||
|
||||
@@ -668,19 +706,47 @@ def _token_via_lines():
|
||||
|
||||
entries = []
|
||||
current = None
|
||||
item_indent = None # dash column of the login list's own items
|
||||
field_indent = None # exact column of the current entry's direct fields
|
||||
for line in lines[start:]:
|
||||
if not line.strip() or line.lstrip().startswith("#"):
|
||||
continue
|
||||
indent = len(line) - len(line.lstrip(" "))
|
||||
if indent <= logins_indent:
|
||||
break
|
||||
item = re.match(r"^\s*-\s*(.*)$", line)
|
||||
rest = item.group(1) if item else line
|
||||
item = re.match(r"^(\s*)-(\s*)(.*)$", line)
|
||||
if item:
|
||||
dash_indent = len(item.group(1))
|
||||
if item_indent is None:
|
||||
item_indent = dash_indent
|
||||
if dash_indent != item_indent:
|
||||
# A more-deeply-indented (nested) or dedented list item: not a
|
||||
# direct login entry. Ignore it and its scope.
|
||||
continue
|
||||
current = {}
|
||||
entries.append(current)
|
||||
pair = re.match(r"^([A-Za-z0-9_]+)\s*:\s*(.*)$", rest.strip())
|
||||
if pair and current is not None:
|
||||
content = item.group(3)
|
||||
if content:
|
||||
# First field shares this line; its column is the direct-field
|
||||
# indent for the rest of the entry.
|
||||
field_indent = dash_indent + 1 + len(item.group(2))
|
||||
pair = re.match(r"^([A-Za-z0-9_]+)\s*:\s*(.*)$", content)
|
||||
if pair:
|
||||
current[pair.group(1)] = _strip_scalar(pair.group(2))
|
||||
else:
|
||||
# Bare "-": the first following field line establishes the indent.
|
||||
field_indent = None
|
||||
continue
|
||||
if current is None:
|
||||
continue
|
||||
if field_indent is None:
|
||||
field_indent = indent
|
||||
if indent != field_indent:
|
||||
# Deeper => a nested sub-map's field (not this entry's own); anything
|
||||
# else at an unexpected column is not a direct field. Skip either way.
|
||||
continue
|
||||
pair = re.match(r"^([A-Za-z0-9_]+)\s*:\s*(.*)$", line.strip())
|
||||
if pair:
|
||||
current[pair.group(1)] = _strip_scalar(pair.group(2))
|
||||
|
||||
for entry in entries:
|
||||
|
||||
Reference in New Issue
Block a user