fix: prevent path traversal via Gitea ref/sha/base/head parameters
test / test (push) Successful in 20s
lint / lint (push) Successful in 22s
docker / lint (pull_request) Successful in 33s
docker / test (pull_request) Successful in 25s
test / test (pull_request) Successful in 38s
lint / lint (pull_request) Successful in 40s
docker / docker-test (pull_request) Successful in 15s
docker / docker-publish (pull_request) Has been skipped
test / test (push) Successful in 20s
lint / lint (push) Successful in 22s
docker / lint (pull_request) Successful in 33s
docker / test (pull_request) Successful in 25s
test / test (pull_request) Successful in 38s
lint / lint (pull_request) Successful in 40s
docker / docker-test (pull_request) Successful in 15s
docker / docker-publish (pull_request) Has been skipped
The ref-like tool arguments (ref, sha, base, head) were only length-limited and were interpolated unencoded into Gitea API URL paths (get_tree, get_commit_diff, compare_refs). Because httpx collapses ".." path segments (RFC 3986), a crafted value such as "../../../../owner/repo/contents/secret" escaped the declared owner/repo prefix. In service-PAT mode this allowed a user authorized on one repository to read arbitrary repositories the service token could reach, and in OAuth mode it bypassed the policy engine's per-repository rules (which never see ref values). Two defense layers: - arguments.py: add _validate_git_ref / GitRef that rejects ".." path segments, leading "/", backslashes, null bytes, control chars, whitespace, and "?"/"#", while preserving legitimate slash refs (feature/foo, v1.2.3). This is what actually closes the traversal. - gitea_client.py: defense-in-depth urllib.parse.quote() on owner/repo (safe="") and ref/sha/base/head/filepath (safe="/") in every repo URL builder, mirroring the existing pattern in server.py. Tests: negative cases for traversal/unsafe chars across all four fields, positive cases for slash-containing refs, length-bound regression, and a URL-layer confinement check. Full suite green (176 passed), coverage 85.64%. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,13 +2,49 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import AfterValidator, BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
_REPO_PART_PATTERN = r"^[A-Za-z0-9._-]{1,100}$"
|
||||
|
||||
|
||||
def _validate_git_ref(value: str) -> str:
|
||||
"""Validate a git ref-like value (ref/sha/base/head) against traversal.
|
||||
|
||||
Refs that legitimately contain ``/`` (e.g. ``feature/foo``, ``release/1.0``)
|
||||
are preserved; only traversal and unsafe URL-path characters are rejected.
|
||||
|
||||
Args:
|
||||
value: Candidate ref, sha, base, or head value.
|
||||
|
||||
Returns:
|
||||
The unchanged value when it is safe.
|
||||
|
||||
Raises:
|
||||
ValueError: When the value could escape the intended repository path.
|
||||
"""
|
||||
# Security decision: block path traversal and absolute references.
|
||||
if ".." in value.split("/"):
|
||||
raise ValueError("ref must not contain '..' path segments")
|
||||
if value.startswith("/"):
|
||||
raise ValueError("ref must not start with '/'")
|
||||
if "\\" in value:
|
||||
raise ValueError("ref must not contain backslashes")
|
||||
if "\x00" in value:
|
||||
raise ValueError("ref must not contain null bytes")
|
||||
if any(ord(char) < 0x20 or ord(char) == 0x7F for char in value):
|
||||
raise ValueError("ref must not contain control characters")
|
||||
if any(char.isspace() for char in value):
|
||||
raise ValueError("ref must not contain whitespace")
|
||||
if "?" in value or "#" in value:
|
||||
raise ValueError("ref must not contain '?' or '#'")
|
||||
return value
|
||||
|
||||
|
||||
GitRef = Annotated[str, AfterValidator(_validate_git_ref)]
|
||||
|
||||
|
||||
class StrictBaseModel(BaseModel):
|
||||
"""Strict model base that rejects unexpected fields."""
|
||||
|
||||
@@ -29,7 +65,7 @@ class RepositoryArgs(StrictBaseModel):
|
||||
class FileTreeArgs(RepositoryArgs):
|
||||
"""Arguments for get_file_tree."""
|
||||
|
||||
ref: str = Field(default="main", min_length=1, max_length=200)
|
||||
ref: GitRef = Field(default="main", min_length=1, max_length=200)
|
||||
recursive: bool = Field(default=False)
|
||||
|
||||
|
||||
@@ -37,7 +73,7 @@ class FileContentsArgs(RepositoryArgs):
|
||||
"""Arguments for get_file_contents."""
|
||||
|
||||
filepath: str = Field(..., min_length=1, max_length=1024)
|
||||
ref: str = Field(default="main", min_length=1, max_length=200)
|
||||
ref: GitRef = Field(default="main", min_length=1, max_length=200)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_filepath(self) -> FileContentsArgs:
|
||||
@@ -55,7 +91,7 @@ class SearchCodeArgs(RepositoryArgs):
|
||||
"""Arguments for search_code."""
|
||||
|
||||
query: str = Field(..., min_length=1, max_length=256)
|
||||
ref: str = Field(default="main", min_length=1, max_length=200)
|
||||
ref: GitRef = Field(default="main", min_length=1, max_length=200)
|
||||
page: int = Field(default=1, ge=1, le=10_000)
|
||||
limit: int = Field(default=25, ge=1, le=100)
|
||||
|
||||
@@ -63,7 +99,7 @@ class SearchCodeArgs(RepositoryArgs):
|
||||
class ListCommitsArgs(RepositoryArgs):
|
||||
"""Arguments for list_commits."""
|
||||
|
||||
ref: str = Field(default="main", min_length=1, max_length=200)
|
||||
ref: GitRef = Field(default="main", min_length=1, max_length=200)
|
||||
page: int = Field(default=1, ge=1, le=10_000)
|
||||
limit: int = Field(default=25, ge=1, le=100)
|
||||
|
||||
@@ -71,14 +107,14 @@ class ListCommitsArgs(RepositoryArgs):
|
||||
class CommitDiffArgs(RepositoryArgs):
|
||||
"""Arguments for get_commit_diff."""
|
||||
|
||||
sha: str = Field(..., min_length=7, max_length=64)
|
||||
sha: GitRef = Field(..., min_length=7, max_length=64)
|
||||
|
||||
|
||||
class CompareRefsArgs(RepositoryArgs):
|
||||
"""Arguments for compare_refs."""
|
||||
|
||||
base: str = Field(..., min_length=1, max_length=200)
|
||||
head: str = Field(..., min_length=1, max_length=200)
|
||||
base: GitRef = Field(..., min_length=1, max_length=200)
|
||||
head: GitRef = Field(..., min_length=1, max_length=200)
|
||||
|
||||
|
||||
class ListIssuesArgs(RepositoryArgs):
|
||||
|
||||
Reference in New Issue
Block a user