feat: safe full-API coverage via classified gitea_request dispatch
Add a deterministic (method, path) read/write classifier with an explicit render-only override table that can only downgrade provably side-effect-free POSTs (markdown/markup) to reads, never the reverse — so a mutating call cannot slip past the write-mode gate. Add a known-Gitea-prefix gate: gitea_request now fails closed on any path whose top segment is not a recognized /api/v1 route instead of passing unknown paths through. Expose raw_relative_segments for the authorization layer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -468,6 +468,67 @@ _RAW_CROSS_REPO_OWNERS = frozenset({"search", "issues"})
|
||||
# Resources whose trailing segments form a file path target for policy checks.
|
||||
_RAW_FILE_RESOURCES = frozenset({"contents", "raw", "media"})
|
||||
|
||||
# Known top-level segments of the Gitea ``/api/v1`` surface. A raw request whose
|
||||
# first path segment is not in this set is rejected (fail closed): we never pass
|
||||
# an unrecognized path straight through to Gitea.
|
||||
KNOWN_API_PREFIXES = frozenset(
|
||||
{
|
||||
"activitypub",
|
||||
"admin",
|
||||
"gitignore",
|
||||
"issues",
|
||||
"label",
|
||||
"licenses",
|
||||
"markdown",
|
||||
"markup",
|
||||
"miscellaneous",
|
||||
"nodeinfo",
|
||||
"notifications",
|
||||
"org",
|
||||
"orgs",
|
||||
"packages",
|
||||
"repos",
|
||||
"repositories",
|
||||
"settings",
|
||||
"signing-key.gpg",
|
||||
"teams",
|
||||
"topics",
|
||||
"user",
|
||||
"users",
|
||||
"version",
|
||||
}
|
||||
)
|
||||
|
||||
# Override table: provably side-effect-free POSTs that may be treated as reads so
|
||||
# they do not needlessly require WRITE_MODE. This table may ONLY ever DOWNGRADE a
|
||||
# write to a read for endpoints that render content and mutate nothing — never
|
||||
# the reverse. Keyed by the final path segment of the endpoint.
|
||||
_RAW_READ_ONLY_POST_LEAVES = frozenset({"markdown", "markup", "raw"})
|
||||
|
||||
|
||||
def raw_is_known_api_path(endpoint: str) -> bool:
|
||||
"""Return whether the endpoint's top segment is a known Gitea API prefix."""
|
||||
return raw_top_segment(endpoint) in KNOWN_API_PREFIXES
|
||||
|
||||
|
||||
def raw_request_is_write(method: str, endpoint: str) -> bool:
|
||||
"""Classify a raw request as read or write from its method and path.
|
||||
|
||||
``GET``/``HEAD`` are reads; every other method is a write — except for the
|
||||
small, explicit override table of render-only POSTs (e.g. markdown/markup),
|
||||
which are reads. The override can only make a request *more* permissive for
|
||||
provably side-effect-free endpoints; it never reclassifies a mutating call as
|
||||
a read, so a misclassified write cannot slip past the write-mode gate.
|
||||
"""
|
||||
upper = method.upper()
|
||||
if upper in {"GET", "HEAD"}:
|
||||
return False
|
||||
if upper == "POST":
|
||||
rel = _raw_relative_segments(endpoint)
|
||||
if rel and rel[-1] in _RAW_READ_ONLY_POST_LEAVES:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def normalize_raw_endpoint(path: str) -> str:
|
||||
"""Normalize a raw API path into an ``/api/v1``-prefixed endpoint.
|
||||
@@ -501,6 +562,11 @@ def _raw_relative_segments(endpoint: str) -> list[str]:
|
||||
return segments[2:] if segments[:2] == ["api", "v1"] else segments
|
||||
|
||||
|
||||
def raw_relative_segments(endpoint: str) -> list[str]:
|
||||
"""Return the endpoint path segments after the ``/api/v1`` prefix (public)."""
|
||||
return _raw_relative_segments(endpoint)
|
||||
|
||||
|
||||
def raw_top_segment(endpoint: str) -> str:
|
||||
"""Return the first path segment after ``/api/v1`` for coarse policy grouping."""
|
||||
rel = _raw_relative_segments(endpoint)
|
||||
|
||||
Reference in New Issue
Block a user