Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
299f412
fix(security): remove nginx CSP header and replace hx-on with nonced …
tomkabel Jun 26, 2026
0ec96dc
fix(security): harden CSRF validation and stop token rotation on HTMX…
tomkabel Jun 26, 2026
38bebb7
fix(security): prefix auth cookies with __Host- and remove email from…
tomkabel Jun 26, 2026
c55e06a
fix(security): add ConfigDict(extra="forbid") to Pydantic input schemas
tomkabel Jun 26, 2026
db4204e
chore(ruff): enable flake8-bandit (S) and flake8-async (ASYNC) rules
tomkabel Jun 26, 2026
893e099
fix(security): pin Docker base images and verify NodeSource GPG key
tomkabel Jun 26, 2026
a3e461d
refactor(types): add comprehensive type annotations for strict mypy c…
tomkabel Jun 26, 2026
06ed702
feat(security): add CSP nonce support and stricter CSP for templates
tomkabel Jun 26, 2026
63e1e02
fix(auth): blacklist refresh tokens and strengthen CSRF handling
tomkabel Jun 26, 2026
25b559d
feat(rate-limit): apply limiter to SSE stream and improve parser
tomkabel Jun 26, 2026
62bd73a
fix(error): secure RNG & improve regex matching; default job max retries
tomkabel Jun 26, 2026
70208c2
fix(models): tidy SQLAlchemy column declarations and core config mess…
tomkabel Jun 26, 2026
a422ace
fix(service): normalize DB query chaining and exception styles
tomkabel Jun 26, 2026
656d716
fix(worker): normalize DB chaining, health host, and minor robustness…
tomkabel Jun 26, 2026
e7ab432
style(api): normalize signatures, docstrings, and trailing commas
tomkabel Jun 26, 2026
48b7f99
fix(resilience/yt-dlp/security): robustness and safety improvements
tomkabel Jun 26, 2026
073080a
chore(gitignore): ignore local scan/audit artifacts and HAR exports
tomkabel Jun 26, 2026
08857aa
fix(tests): make jitter patchable and avoid youtube-only options in n…
tomkabel Jun 26, 2026
76bcad9
fix(yt-dlp): prevent subdomain bypass and embed youtube opts as dict …
tomkabel Jun 26, 2026
44a131b
fix(yt-dlp): extend subdomain-bypass detection to all platforms
tomkabel Jun 26, 2026
6ef17e5
fix(error): move module-level assignment below all imports
tomkabel Jun 26, 2026
0c99832
fix(yt-dlp): add type annotation to _host_matches inner function
tomkabel Jun 26, 2026
1c38ad3
feat(worker): Phase 2 hybrid routing — TikTok/Instagram/X via browser…
tomkabel Jun 28, 2026
511e668
fix: address all valid PR review issues from gh-pr-comments.md
tomkabel Aug 1, 2026
200ab0a
feat(browser-downloader): enhance with VDH/FlowPick patterns
tomkabel Aug 1, 2026
016df2d
fix: outbox dedup, chaos refactor, browser-downloader patches, and li…
tomkabel Aug 1, 2026
6017e01
ruff formatting
tomkabel Aug 1, 2026
fa247e7
fix: resolve remaining pre-commit warnings in worker/main.py
tomkabel Aug 1, 2026
6bc89f0
format pnpm workspace
tomkabel Aug 1, 2026
15d6b2a
fix: resolve PR review issues — security, correctness, and robustness
tomkabel Aug 1, 2026
9dff171
commit
tomkabel Aug 1, 2026
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
16 changes: 10 additions & 6 deletions .github/workflows/fastapi-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jobs:
run: hatch run lint:format-check

- name: Install pnpm
uses: pnpm/action-setup@008330803749db0355799c700092d9a85fd074e9
uses: pnpm/action-setup@008330803749db0355799c700092d9a85fd074e9 # v4.0.0

- name: Install Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
Expand All @@ -76,7 +76,7 @@ jobs:
run: yamllint --config-file .yamllint .github/ infra/ docker-compose*.yml

- name: Run ShellCheck
run: shellcheck --severity=warning scripts/*.sh entrypoint.sh migrate.sh
run: shellcheck --severity=warning scripts/*.sh entrypoint.sh migrate.sh worker/entrypoint-worker.sh

# ============================================
# TYPE CHECK - Static type analysis
Expand Down Expand Up @@ -164,7 +164,7 @@ jobs:
image: postgres:15
env:
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_pass
POSTGRES_PASSWORD: test_pw
POSTGRES_DB: test_db
options: >-
--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
Expand Down Expand Up @@ -200,20 +200,24 @@ jobs:

- name: Create test environment
run: |
SK="$(openssl rand -hex 32)"
cp .env.example .env
sed -i 's|^DB_PASSWORD=.*|DB_PASSWORD=test_pass|' .env
sed -i 's|^SECRET_KEY=$|SECRET_KEY=test-secret-key-for-ci-at-least-32-chars-long|' .env
sed -i "s|^DB_PASSWORD=.*|DB_PASSWORD=test_pw|" .env
sed -i "s|^SECRET_KEY=$|SECRET_KEY=${SK}|" .env
echo "PGPASSWORD=test_pw" >> "$GITHUB_ENV"

- name: Create database schema
run: |
uv run python -c "
import asyncio
import os
from sqlalchemy.ext.asyncio import create_async_engine
from core.database import Base
from core.models import User, DownloadJob

async def init_db():
engine = create_async_engine('postgresql+asyncpg://test_user:test_pass@localhost:5432/test_db')
db_pass = os.environ['PGPASSWORD']
engine = create_async_engine(f'postgresql+asyncpg://test_user:{db_pass}@localhost:5432/test_db')
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await engine.dispose()
Expand Down
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,15 @@ docs/CRITIQUE-ISSUES.md
.agents/
.kilocode/skills/*
.agents/.story-automator-active

# Local scan and audit artifacts (not checked in)
.betterleaks.toml
.betterleaksignore
betterleaks-setup.md
betterleaks.sarif
SOTA-AUDIT-REPORT.md
fail-console.md
fail-result.md
fail.har
# HAR files and local exports that are ephemeral
notegpt-clone/
4 changes: 2 additions & 2 deletions .secrets.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -146,14 +146,14 @@
{
"type": "Secret Keyword",
"filename": ".github/workflows/fastapi-test.yml",
"hashed_secret": "c94d65f02a652d11c2e5c2e1ccf38dce5a076e1e",
"hashed_secret": "d986d7729a3ca93e8fb8d9c0f1962564aed9f829",
"is_verified": false,
"line_number": 167
},
{
"type": "Secret Keyword",
"filename": ".github/workflows/fastapi-test.yml",
"hashed_secret": "89edba72d4aef5098771cee787b40b81af666eb3",
"hashed_secret": "fac0e4eec4d11a3bbad583ff2d750d900a70bf72",
"is_verified": false,
"line_number": 202
}
Expand Down
13 changes: 8 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
# ============================================
# Stage 1: Python Dependency Builder
# ============================================
FROM python:3.12-slim AS python-builder
FROM python@sha256:6c4dd321d176d61ea848dc8c73a4f7dbae8f70e0ee48bb411ea2f045b599fa8e AS python-builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1

Expand All @@ -29,7 +29,7 @@ ENV PATH="/opt/venv/bin:$PATH" \
UV_COMPILE_BYTECODE=1

# Install uv binary (single static binary, ~25MB, not copied to final image)
COPY --from=ghcr.io/astral-sh/uv:0.6 /uv /bin/uv
COPY --from=ghcr.io/astral-sh/uv@sha256:4a6c9444b126bd325fba904bff796bf91fb777bf6148d60109c4cb1de2ffc497 /uv /bin/uv

# Copy manifest and lockfile first → cacheable dependency layer
COPY pyproject.toml uv.lock ./
Expand All @@ -41,7 +41,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# ============================================
# Stage 2: Frontend Builder
# ============================================
FROM node:20-alpine AS frontend-builder
FROM node@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS frontend-builder
WORKDIR /app

# Install pnpm for package management (version pinned in frontend/package.json packageManager field)
Expand Down Expand Up @@ -102,7 +102,7 @@ RUN mkdir -p /app/app/static/swagger && \
# ============================================
# Stage 4: Runtime Base
# ============================================
FROM python:3.12-slim AS runtime-base
FROM python@sha256:6c4dd321d176d61ea848dc8c73a4f7dbae8f70e0ee48bb411ea2f045b599fa8e AS runtime-base
ENV PYTHONDONTWRITEBYTECODE=1

# Install runtime dependencies with apt cache mounts
Expand All @@ -115,7 +115,10 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
curl \
gnupg \
&& mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key -o /tmp/nodesource-repo.gpg.key \
&& echo "b42e0321dabdc24e892115da705cf061167eac12a317f23d329862d0aa0a271d /tmp/nodesource-repo.gpg.key" | sha256sum -c - \
&& gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg /tmp/nodesource-repo.gpg.key \
&& rm /tmp/nodesource-repo.gpg.key \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends nodejs \
Expand Down
2 changes: 1 addition & 1 deletion app/api/dependencies/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ async def get_current_user_from_cookie(
if credentials is not None:
token = credentials.credentials
else:
token = request.cookies.get("access_token")
token = request.cookies.get("__Host-access_token")
return await _resolve_user_from_token(db, token, expected_type=ACCESS_TOKEN_TYPE)


Expand Down
6 changes: 3 additions & 3 deletions app/api/docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def register_docs_routes(app: FastAPI) -> None:
"""Register custom Swagger UI and ReDoc routes."""

@app.get("/docs", include_in_schema=False)
async def custom_docs(request: Request):
async def custom_docs(request: Request) -> HTMLResponse:
nonce = request.state.nonce
swagger_dir = APP_DIR / "static" / "swagger"
if swagger_dir.exists():
Expand Down Expand Up @@ -69,7 +69,7 @@ async def custom_docs(request: Request):
return docs_response

@app.get("/redoc", include_in_schema=False)
async def custom_redoc(request: Request):
async def custom_redoc(request: Request) -> HTMLResponse:
nonce = request.state.nonce
redoc_dir = APP_DIR / "static" / "redoc"
if redoc_dir.exists():
Expand Down Expand Up @@ -97,7 +97,7 @@ async def custom_redoc(request: Request):
def _inject_inline_script_nonce(html: str, nonce: str) -> str:
"""Add the request nonce to FastAPI's generated inline docs script."""
return html.replace(
"<script>\n const ui =", f'<script nonce="{nonce}">\n const ui ='
"<script>\n const ui =", f'<script nonce="{nonce}">\n const ui =',
).replace("<script>\nconst ui =", f'<script nonce="{nonce}">\nconst ui =')


Expand Down
8 changes: 4 additions & 4 deletions app/api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ async def http_exception_handler(request: Request, exc: StarletteHTTPException)


async def validation_exception_handler(
request: Request, exc: RequestValidationError
request: Request, exc: RequestValidationError,
) -> JSONResponse:
"""Handle validation errors with standardized error response."""
request_id = getattr(request.state, "request_id", "unknown")
Expand Down Expand Up @@ -107,10 +107,10 @@ def register_exception_handlers(app: FastAPI) -> None:
"""Register rate-limit and global exception handlers."""
app.add_exception_handler(RateLimitExceeded, rate_limit_exceeded_handler)
app.add_exception_handler(
StarletteHTTPException, cast(ExceptionHandler, http_exception_handler)
StarletteHTTPException, cast("ExceptionHandler", http_exception_handler),
)
app.add_exception_handler(
RequestValidationError,
cast(ExceptionHandler, validation_exception_handler),
cast("ExceptionHandler", validation_exception_handler),
)
app.add_exception_handler(Exception, cast(ExceptionHandler, general_exception_handler))
app.add_exception_handler(Exception, cast("ExceptionHandler", general_exception_handler))
6 changes: 5 additions & 1 deletion app/api/middleware/prometheus.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"""Prometheus metrics collection middleware."""

import time
from collections.abc import Awaitable, Callable

from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from starlette.routing import BaseRoute

from core.metrics import HTTP_REQUEST_DURATION, HTTP_REQUESTS
Expand All @@ -12,7 +14,9 @@
class PrometheusMiddleware(BaseHTTPMiddleware):
"""Middleware to collect HTTP metrics."""

async def dispatch(self, request: Request, call_next):
async def dispatch(
self, request: Request, call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
if request.url.path == "/metrics":
return await call_next(request)

Expand Down
7 changes: 6 additions & 1 deletion app/api/middleware/request_body_size.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"""Request body size limiting middleware."""

from collections.abc import Awaitable, Callable

from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response

from app.schemas.error import ErrorCode, error_response_dict

Expand All @@ -12,7 +15,9 @@ class RequestBodySizeMiddleware(BaseHTTPMiddleware):

MAX_BODY_SIZE = 1024 * 1024

async def dispatch(self, request: Request, call_next):
async def dispatch(
self, request: Request, call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
if request.method in ("GET", "HEAD", "OPTIONS"):
return await call_next(request)

Expand Down
5 changes: 3 additions & 2 deletions app/api/middleware/security_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,14 @@ async def add_security_headers(request: Request, call_next: Any) -> Any:
f"default-src 'self'; "
f"script-src 'self' 'nonce-{nonce}' 'unsafe-hashes' "
f"{FONT_ONLOAD_HANDLER_HASH}; "
f"style-src 'self' https://fonts.googleapis.com 'unsafe-inline'; "
f"style-src 'self' 'nonce-{nonce}' https://fonts.googleapis.com; "
f"font-src 'self' https://fonts.gstatic.com; "
f"img-src 'self' data: blob:; "
f"connect-src 'self'; "
f"frame-ancestors 'none'; "
f"base-uri 'self'; "
f"form-action 'self'"
f"form-action 'self'; "
f"object-src 'none'"
)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
Expand Down
29 changes: 19 additions & 10 deletions app/api/rate_limit_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import os
import re
from collections.abc import Callable
from typing import Any

from slowapi import Limiter
from slowapi.errors import RateLimitExceeded
Expand All @@ -19,15 +21,19 @@
class NoOpLimiter:
"""A no-op limiter that doesn't enforce rate limits."""

def limit(self, *args, **kwargs):
def limit(
self,
*args: Any,
**kwargs: Any,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Return a no-op decorator."""

def noop_decorator(func):
def noop_decorator(func: Callable[..., Any]) -> Callable[..., Any]:
return func

return noop_decorator

async def __call__(self, request, *args, **kwargs):
async def __call__(self, request: Request, *args: Any, **kwargs: Any) -> None:
"""Allow all requests."""


Expand All @@ -44,11 +50,11 @@ def _parse_retry_after(detail: str) -> int:
match = re.match(r"(\d+)\s+per\s+(\d+)\s+(\w+)", detail)
if not match:
return 60 # Default to 60 seconds if parsing fails
limit, _window, unit = match.groups()
limit = int(limit)
_limit, window, unit = match.groups()
_limit = int(_limit)
window = int(window)
unit = unit.lower()
if unit.endswith("s"):
unit = unit[:-1] # "minutes" → "minute", "secs" → "sec"
unit = unit.removesuffix("s") # "minutes" → "minute", "secs" → "sec"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Handle "sec" variant
if unit == "sec":
unit = "second"
Expand All @@ -59,11 +65,12 @@ def _parse_retry_after(detail: str) -> int:
"day": 86400,
}
multiplier = multipliers.get(unit, 60) # Default to minute (60s)
return limit * multiplier
return window * multiplier


async def rate_limit_exceeded_handler(
request: Request, exc: Exception
request: Request,
exc: Exception,
) -> JSONResponse | HTMLResponse:
"""Handle rate limit exceeded errors with standardized error response.

Expand All @@ -83,7 +90,9 @@ async def rate_limit_exceeded_handler(
# even if the JS error handler swaps the response into the DOM target.
if request.headers.get("HX-Request") == "true":
return HTMLResponse(
status_code=429, content=_rate_limit_error_html(detail), headers=headers
status_code=429,
content=_rate_limit_error_html(detail),
headers=headers,
)

return JSONResponse(
Expand Down
Loading
Loading