From 59c445f7f69b27f481d3d395b43ab8b20b79c1d3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 6 Jun 2026 15:52:06 +0000 Subject: [PATCH] security: fix critical vulnerabilities in Python source - config.py: Override __repr__ to mask github_token and anthropic_api_key, preventing accidental secret exposure in logs and tracebacks - github_client.py: Validate owner/repo path segments against a safe charset regex to prevent URL path injection via crafted webhook payloads - webhook_handler.py: Add parse_raw_verified() that enforces signature verification before parsing, and document that parse_raw() alone is unsafe Co-Authored-By: dominicpape --- src/opencode_github/config.py | 9 +++++++++ src/opencode_github/github_client.py | 20 ++++++++++++++++++++ src/opencode_github/webhook_handler.py | 24 +++++++++++++++++++++++- 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/opencode_github/config.py b/src/opencode_github/config.py index 705b120..955417f 100644 --- a/src/opencode_github/config.py +++ b/src/opencode_github/config.py @@ -17,6 +17,15 @@ class Config: allowed_commands: list[str] = field(default_factory=lambda: ["/oc", "/opencode"]) request_timeout: int = 30 + def __repr__(self) -> str: + """Mask secrets to prevent accidental exposure in logs or tracebacks.""" + return ( + f"Config(github_token='***', anthropic_api_key='***', " + f"model={self.model!r}, github_api_url={self.github_api_url!r}, " + f"allowed_commands={self.allowed_commands!r}, " + f"request_timeout={self.request_timeout!r})" + ) + @classmethod def from_env(cls, environ: dict[str, str] | None = None) -> Config: """Build a ``Config`` from environment variables. diff --git a/src/opencode_github/github_client.py b/src/opencode_github/github_client.py index dfb357e..961e4dc 100644 --- a/src/opencode_github/github_client.py +++ b/src/opencode_github/github_client.py @@ -2,11 +2,21 @@ from __future__ import annotations +import re from dataclasses import dataclass from typing import Any import httpx +_SAFE_PATH_SEGMENT = re.compile(r"^[a-zA-Z0-9._-]+$") + + +def _validate_path_segment(value: str, name: str) -> str: + """Ensure a URL path segment contains no path-traversal characters.""" + if not value or not _SAFE_PATH_SEGMENT.match(value): + raise ValueError(f"Invalid {name}: {value!r}") + return value + @dataclass(frozen=True) class PullRequest: @@ -87,6 +97,8 @@ async def _request(self, method: str, path: str, **kwargs: Any) -> Any: return resp.json() async def get_pull_request(self, owner: str, repo: str, number: int) -> PullRequest: + _validate_path_segment(owner, "owner") + _validate_path_segment(repo, "repo") data = await self._request("GET", f"/repos/{owner}/{repo}/pulls/{number}") return PullRequest( number=data["number"], @@ -99,6 +111,8 @@ async def get_pull_request(self, owner: str, repo: str, number: int) -> PullRequ async def list_issue_comments( self, owner: str, repo: str, issue_number: int ) -> list[IssueComment]: + _validate_path_segment(owner, "owner") + _validate_path_segment(repo, "repo") data = await self._request("GET", f"/repos/{owner}/{repo}/issues/{issue_number}/comments") return [ IssueComment( @@ -113,6 +127,8 @@ async def list_issue_comments( async def create_issue_comment( self, owner: str, repo: str, issue_number: int, body: str ) -> IssueComment: + _validate_path_segment(owner, "owner") + _validate_path_segment(repo, "repo") data = await self._request( "POST", f"/repos/{owner}/{repo}/issues/{issue_number}/comments", @@ -128,6 +144,8 @@ async def create_issue_comment( async def add_reaction( self, owner: str, repo: str, comment_id: int, reaction: str = "+1" ) -> None: + _validate_path_segment(owner, "owner") + _validate_path_segment(repo, "repo") await self._request( "POST", f"/repos/{owner}/{repo}/issues/comments/{comment_id}/reactions", @@ -135,4 +153,6 @@ async def add_reaction( ) async def get_repo(self, owner: str, repo: str) -> dict[str, Any]: + _validate_path_segment(owner, "owner") + _validate_path_segment(repo, "repo") return await self._request("GET", f"/repos/{owner}/{repo}") diff --git a/src/opencode_github/webhook_handler.py b/src/opencode_github/webhook_handler.py index 9e3fa68..99400e1 100644 --- a/src/opencode_github/webhook_handler.py +++ b/src/opencode_github/webhook_handler.py @@ -116,10 +116,32 @@ def parse_payload(event_type: EventType, payload: dict[str, Any]) -> WebhookEven def parse_raw(event_header: str, body: bytes) -> WebhookEvent | None: - """Convenience wrapper: classify, decode JSON, and parse in one call.""" + """Convenience wrapper: classify, decode JSON, and parse in one call. + + .. warning:: + This function does NOT verify the webhook signature. Call + :func:`verify_signature` before this or use :func:`parse_raw_verified` + for a safe all-in-one path. + """ event_type = classify_event(event_header) try: payload = json.loads(body) except (json.JSONDecodeError, UnicodeDecodeError): return None return parse_payload(event_type, payload) + + +def parse_raw_verified( + event_header: str, + body: bytes, + signature: str, + secret: str, +) -> WebhookEvent | None: + """Classify, verify signature, decode JSON, and parse — safe all-in-one path. + + Returns ``None`` if signature verification fails or the payload is + unsupported/malformed. + """ + if not verify_signature(body, signature, secret): + return None + return parse_raw(event_header, body)