Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,13 @@ dependencies = [
"sqlalchemy>=2.0.0",
"psycopg[binary]>=3.2,<4",
"aiosqlite>=0.19.0",
"python-jose[cryptography]>=3.3.0",
# NOTE: python-jose was removed here. It was never imported by this
# codebase -- the only "from jose import ..." occurrence is inside a string
# template in backend/code_generator.py, which writes its own
# requirements.txt for the project it generates. Declaring it dragged in
# ecdsa, whose GHSA-wj6h-64fc-37mp has no patched release. passlib below is
# the same template-only case but carries no advisory; it can be dropped
# too if generated-project deps are confirmed self-contained.
"passlib[bcrypt]>=1.7.4",
"python-decouple>=3.8",
"structlog>=23.2.0",
Expand Down Expand Up @@ -91,6 +97,10 @@ dev = [
"ipykernel>=6.25.0",
"notebook>=7.0.0",
"locust==2.46.0",
# Test-time only. TestGeneratedFastAPIBehaviour executes the FastAPI app
# that code_generator emits, and that app imports jwt. EventRelay itself
# never imports it, so it does not belong in the runtime dependencies.
"pyjwt>=2.10.1",
]
docs = [
"mkdocs>=1.5.0",
Expand Down
4 changes: 3 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ aiosqlite>=0.19.0
alembic>=1.12.0

# Security & Auth
python-jose[cryptography]>=3.3.0
# python-jose removed: never imported here (only inside a code_generator.py
# string template, which emits its own requirements.txt). It pulled in ecdsa,
# whose GHSA-wj6h-64fc-37mp has no patched release.
passlib[bcrypt]>=1.7.4

# Configuration
Expand Down
78 changes: 65 additions & 13 deletions shared/libs/youtube_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import logging
import os
import random
import re
import time
import urllib.parse
from dataclasses import dataclass
Expand Down Expand Up @@ -72,20 +73,71 @@ def _get_transcript_proxy_config() -> GenericProxyConfig | None:
return GenericProxyConfig(http_url=url, https_url=url)


def _redact_proxy_credentials(text: str) -> str:
"""Strip user:pass credentials of the configured proxy URL from text."""
url = os.getenv("WEBSHARE_PROXY_URL", "").strip()
if not url or url not in text:
return text
# Matches the ``user[:password]@`` userinfo segment of any URL. Kept byte-identical
# to the canonical copy in ``src/youtube_extension/utils/proxy.py``; see that module
# for the full rationale. In short: the classes exclude the authority delimiters
# (whitespace, "/", "?", "#") so paths and query strings containing "@" are never
# mistaken for credentials, but they permit a literal "@" so an unencoded one in
# the password is consumed whole rather than leaving the tail behind.
_USERINFO_RE = re.compile(
r"(?P<scheme>[A-Za-z][A-Za-z0-9+.\-]*://)"
r"(?P<user>[^\s/:?#]*)"
r"(?::(?P<password>[^\s/?#]*))?"
r"@"
)

_REDACTED = "***"
_UNPRINTABLE = "<unprintable error>"
_REDACTION_FAILED = "<redaction failed>"


def _redact_proxy_credentials(text: Any) -> str:
"""Strip URL userinfo (``user:pass@``) from ``text``.

Two passes: exact replacement of the configured ``WEBSHARE_PROXY_URL`` (which
preserves the host, so operators can still tell which proxy was in play),
then a generic ``scheme://user:pass@`` sweep that catches credentials never
matching the env value verbatim -- a normalised or percent-encoded form
echoed back by yt-dlp, a ``CalledProcessError`` repr of the argv, or a
different proxy variable such as ``HTTPS_PROXY``.

Never raises: every caller here is an exception handler, where a failure
would mask the original error.
"""
if isinstance(text, str):
candidate = text
else:
try:
candidate = str(text)
except Exception: # noqa: BLE001 - a hostile __str__ must not propagate
return _UNPRINTABLE

try:
parsed = urllib.parse.urlparse(url)
netloc = parsed.hostname or ""
if parsed.port:
netloc = f"{netloc}:{parsed.port}"
redacted = parsed._replace(netloc=netloc).geturl()
except (ValueError, AttributeError):
redacted = "<proxy-url>"
return text.replace(url, redacted)
return _redact(candidate)
except Exception: # noqa: BLE001 - never return text we cannot vouch for
return _REDACTION_FAILED


def _redact(text: str) -> str:
"""Run the two redaction passes over an already-stringified ``text``."""
url = os.getenv("WEBSHARE_PROXY_URL", "").strip()
if url and url in text:
try:
parsed = urllib.parse.urlparse(url)
netloc = parsed.hostname or ""
if parsed.port:
netloc = f"{netloc}:{parsed.port}"
redacted = parsed._replace(netloc=netloc).geturl()
except (ValueError, AttributeError):
redacted = "<proxy-url>"
text = text.replace(url, redacted)

def _mask(match: re.Match[str]) -> str:
if match.group("password") is None:
return f"{match.group('scheme')}{_REDACTED}@"
return f"{match.group('scheme')}{_REDACTED}:{_REDACTED}@"

return _USERINFO_RE.sub(_mask, text)

class YouTubeErrorType(Enum):
"""YouTube API specific error types"""
Expand Down
16 changes: 11 additions & 5 deletions src/youtube_extension/backend/code_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -463,9 +463,14 @@ async def _generate_python_api(self, project_path: Path, video_analysis: dict, f
if "database" in features:
requirements.append("sqlalchemy==2.0.23")
if "authentication" in features:
# The generated main.py imports passlib.context; omitting it here
# produced a project that fails at import time.
requirements.append("python-jose[cryptography]==3.3.0")
# The generated main.py imports jwt and passlib.context; omitting
# either here produced a project that fails at import time.
#
# PyJWT rather than python-jose: python-jose depends on ecdsa,
# whose GHSA-wj6h-64fc-37mp has no patched release, so every
# generated project inherited an unfixable advisory. PyJWT is
# maintained and its encode/decode signatures are the same.
requirements.append("pyjwt>=2.10.1")
requirements.append("passlib[bcrypt]==1.7.4")

# Generate main.py
Expand Down Expand Up @@ -1115,7 +1120,8 @@ def _generate_fastapi_main(self, title: str, features: list[str]) -> str:
if "authentication" in features:
scaffolding_endpoints.append("POST /auth/login")
auth_imports = '''
from jose import JWTError, jwt
import jwt
from jwt import PyJWTError
from passlib.context import CryptContext'''
auth_code = '''
# ─── Authentication ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -1162,7 +1168,7 @@ def decode_access_token(token: str) -> dict:
key = _require_secret_key()
try:
return jwt.decode(token, key, algorithms=[ALGORITHM])
except JWTError as exc:
except PyJWTError as exc:
raise HTTPException(status_code=401, detail="Invalid or expired token") from exc


Expand Down
101 changes: 88 additions & 13 deletions src/youtube_extension/utils/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import logging
import os
import re
import urllib.parse
from typing import Any

Expand All @@ -28,6 +29,38 @@

_PROXY_ENV_VAR = "WEBSHARE_PROXY_URL"

# Matches the ``user[:password]@`` userinfo segment of any URL.
#
# The classes exclude whitespace, "/", "?" and "#" -- the delimiters that end a
# URL authority -- so a path, query, or fragment containing "@" (e.g.
# "https://example.com/a@b" or "https://example.com?e=a@b") is never mistaken
# for credentials, and a match can never span from one URL into the next.
#
# They deliberately *permit* a literal "@". RFC 3986 requires "@" inside
# userinfo to be percent-encoded, but real *_PROXY values routinely carry a raw
# one. Because the classes are greedy, the engine backtracks to the LAST "@"
# inside the authority, so an unencoded "@" in the password
# ("http://user:pa@ss@host") is consumed whole instead of the match stopping at
# the first separator and leaving the password tail behind.
#
# The user may be empty so credentials with no username ("http://:pass@host")
# are still redacted.
_USERINFO_RE = re.compile(
r"(?P<scheme>[A-Za-z][A-Za-z0-9+.\-]*://)"
r"(?P<user>[^\s/:?#]*)"
r"(?::(?P<password>[^\s/?#]*))?"
r"@"
)

_REDACTED = "***"

# Returned when the input cannot be stringified, or when redaction itself
# fails. Both are fail-closed: emitting a fixed placeholder is preferable to
# raising (which would mask the original error) or to returning text we cannot
# guarantee is clean (which could leak the credential we are trying to strip).
_UNPRINTABLE = "<unprintable error>"
_REDACTION_FAILED = "<redaction failed>"


def get_proxy_url() -> str | None:
"""Return the validated Webshare proxy URL, or None for direct connection."""
Expand Down Expand Up @@ -67,17 +100,59 @@ def get_transcript_proxy_config() -> Any | None:
return GenericProxyConfig(http_url=url, https_url=url)


def redact_proxy_credentials(text: str) -> str:
"""Strip user:pass credentials of the configured proxy URL from text."""
url = os.getenv(_PROXY_ENV_VAR, "").strip()
if not url or url not in text:
return text
def redact_proxy_credentials(text: Any) -> str:
"""Strip URL userinfo (``user:pass@``) from ``text``.

Two passes, because either alone is insufficient:

1. Exact replacement of the configured ``WEBSHARE_PROXY_URL`` -- preserves
the host so operators can still tell *which* proxy was in play.
2. A generic ``scheme://user:pass@`` sweep -- catches credentials that never
match the env value verbatim: yt-dlp echoing a normalised or
percent-encoded form back on stderr, a ``CalledProcessError`` repr of the
argv, or a different proxy variable (``HTTPS_PROXY`` and friends)
entirely.

Pass 1 alone was the previous behaviour and leaked in all of those cases,
because it bailed out whenever the configured URL did not appear in the text
byte-for-byte.

Always returns a string and never raises; it is called from exception
handlers, where a failure would mask the original error. If ``text`` cannot
be stringified (a ``__str__`` that itself raises) or redaction fails, a
fixed non-sensitive placeholder is returned instead.
"""
if isinstance(text, str):
candidate = text
else:
try:
candidate = str(text)
except Exception: # noqa: BLE001 - a hostile __str__ must not propagate
return _UNPRINTABLE

try:
parsed = urllib.parse.urlparse(url)
netloc = parsed.hostname or ""
if parsed.port:
netloc = f"{netloc}:{parsed.port}"
redacted = parsed._replace(netloc=netloc).geturl()
except (ValueError, AttributeError):
redacted = "<proxy-url>"
return text.replace(url, redacted)
return _redact(candidate)
except Exception: # noqa: BLE001 - never return text we cannot vouch for
return _REDACTION_FAILED


def _redact(text: str) -> str:
"""Run the two redaction passes over an already-stringified ``text``."""
url = os.getenv(_PROXY_ENV_VAR, "").strip()
if url and url in text:
try:
parsed = urllib.parse.urlparse(url)
netloc = parsed.hostname or ""
if parsed.port:
netloc = f"{netloc}:{parsed.port}"
redacted = parsed._replace(netloc=netloc).geturl()
except (ValueError, AttributeError):
redacted = "<proxy-url>"
text = text.replace(url, redacted)

def _mask(match: re.Match[str]) -> str:
if match.group("password") is None:
return f"{match.group('scheme')}{_REDACTED}@"
return f"{match.group('scheme')}{_REDACTED}:{_REDACTED}@"

return _USERINFO_RE.sub(_mask, text)
6 changes: 5 additions & 1 deletion tests/unit/test_code_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -1382,4 +1382,8 @@ def test_auth_projects_declare_their_password_hashing_dependency(

requirements = (project_dir / "requirements.txt").read_text()
assert "passlib" in requirements
assert "python-jose" in requirements
# PyJWT, not python-jose: the latter depends on ecdsa, whose
# GHSA-wj6h-64fc-37mp has no patched release, so every generated
# project inherited an unfixable advisory.
assert "pyjwt" in requirements
assert "python-jose" not in requirements
Loading
Loading