Skip to content

Commit 49b0258

Browse files
authored
fix(security): close RCE via PR title, stop leaking secrets, harden runner (#65)
The runner entrypoint built a sed script out of the PR title, so a title could close the s command and chain GNU sed's `e` -- which shells out. Reproduced in the real image: a title of benign-looking title|;e id > /tmp/PWNED # executed as the runner user, which holds CLAUDE_CODE_OAUTH_TOKEN and GITHUB_TOKEN, and the rendered prompt showed no trace of the payload. The same line broke on any title containing a plain `|`, aborting before emit_error could run and leaving the frontend a dead session with no reason. Substitution now goes through awk with the value passed via the environment and matched with index()/substr(), so no byte of PR-controlled data is parsed as a pattern or as code. Settings rendered every credential in cleartext through repr(), while sentry_sdk.init defaults to include_local_variables=True and a live `settings` sits in a dozen frames that can raise -- one unhandled 500 was enough to ship the whole set to a third party. Credentials are SecretStr now; SecretStr defines __len__, so existing truthiness checks still hold. Also: - PidsLimit, CapDrop ALL and no-new-privileges on the one container that runs `claude --dangerously-skip-permissions` over attacker-authored PRs - rate limit the SQLAdmin login: slowapi resolves handlers via hasattr(route, "endpoint"), so the whole /admin Mount was exempt and guessing against the shared password was unbounded - ENVIRONMENT is a Literal, so a typo can no longer skip the production secret validation silently - CORS registered last (outermost) per the documented convention, plus expose_headers so the frontend can read X-Request-ID - shellcheck in CI over the entrypoint, the file where this bug lived and the only code in the repo with no linter over it - drop ANTHROPIC_API_KEY and the two LEMONSQUEEZY_* settings, unread since the pivot, and the unused PR_DIFF shell variable shellcheck flagged
1 parent f66bf67 commit 49b0258

35 files changed

Lines changed: 368 additions & 136 deletions

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ CONTAINER_TTL_SECONDS=900
7676
# Number of uvicorn workers (default: 4)
7777
UVICORN_WORKERS=4
7878

79+
# --- Database pool, PER WORKER ----------------------------------------------
80+
# UVICORN_WORKERS x (DB_POOL_SIZE + DB_MAX_OVERFLOW) must stay below the
81+
# Postgres server's max_connections (default 100). Defaults give 4 x 15 = 60.
82+
# DB_POOL_SIZE=10
83+
# DB_MAX_OVERFLOW=5
84+
7985
# Absolute path to skills/ directory on the Docker host.
8086
# Required for Docker-in-Docker volume mounts (the API container mounts this
8187
# path into claude-runner containers). Must be an absolute path on the HOST,

.github/workflows/ci.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ on:
77
branches: [main]
88

99
jobs:
10+
# The runner entrypoint executes untrusted PR content and was the only code
11+
# in the repo with no linter over it. A shell injection lived there
12+
# undetected; shellcheck is what stops the next one.
13+
lint-shell:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@v4
17+
- run: shellcheck infra/docker/claude-runner/entrypoint.sh scripts/*.sh
18+
1019
lint-backend:
1120
runs-on: ubuntu-latest
1221
defaults:

apps/api/pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ dependencies = [
1818
"structlog>=24.4.0",
1919
"sentry-sdk[fastapi]>=2.19.0",
2020
"slowapi>=0.1.9",
21+
# Pulled in by slowapi, declared because it is imported directly to rate
22+
# limit the SQLAdmin login, which slowapi's middleware cannot see: it
23+
# skips anything mounted rather than routed.
24+
"limits>=3.0.0",
2125
"itsdangerous>=2.2.0",
2226
"aiodocker>=0.23.0",
2327
]

apps/api/src/helprs/admin/views.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,14 @@
33
import secrets
44

55
import structlog
6+
from limits import parse
7+
from limits.storage import MemoryStorage
8+
from limits.strategies import FixedWindowRateLimiter
9+
from slowapi.util import get_remote_address
610
from sqladmin import Admin, ModelView
711
from sqladmin.authentication import AuthenticationBackend
12+
from sqlalchemy.ext.asyncio import AsyncEngine
13+
from starlette.applications import Starlette
814
from starlette.requests import Request
915

1016
from helprs.core.config import get_settings
@@ -94,6 +100,18 @@ class WebhookEventAdmin(ModelView, model=WebhookEvent):
94100
icon = "fa-solid fa-bolt"
95101

96102

103+
# SlowAPIMiddleware cannot protect this login. It resolves the handler through
104+
# `_find_route_handler`, which requires `hasattr(route, "endpoint")`; SQLAdmin
105+
# is a Mount, so the whole /admin subtree is treated as exempt and skipped.
106+
# Guessing was therefore unlimited against a single shared password that
107+
# guards user rows and BYOK ciphertext, so the limit is enforced here instead.
108+
#
109+
# Storage is per-process, like the app's other limiter: with several uvicorn
110+
# workers the effective budget is this figure times the worker count. That is
111+
# still a hard bound on guessing, which is the point.
112+
_LOGIN_ATTEMPT_LIMIT = parse("5/minute")
113+
114+
97115
class AdminAuth(AuthenticationBackend):
98116
"""Password authentication for the admin panel.
99117
@@ -104,15 +122,26 @@ class AdminAuth(AuthenticationBackend):
104122
opening it (``setup_admin`` refuses to mount in that case).
105123
"""
106124

125+
def __init__(self, secret_key: str) -> None:
126+
super().__init__(secret_key=secret_key)
127+
# One counter per mounted panel rather than a module global, so the
128+
# lifetime matches the app's.
129+
self._login_limiter = FixedWindowRateLimiter(MemoryStorage())
130+
107131
async def login(self, request: Request) -> bool:
132+
client = get_remote_address(request)
133+
if not self._login_limiter.hit(_LOGIN_ATTEMPT_LIMIT, client):
134+
logger.warning("admin_login_rate_limited", client=client)
135+
return False
136+
108137
form = await request.form()
109138
password = form.get("password")
110139
if not isinstance(password, str):
111140
return False
112141

113142
# compare_digest("", "") is True, so an unset password would otherwise
114143
# authenticate an empty form field.
115-
expected = get_settings().ADMIN_PASSWORD
144+
expected = get_settings().ADMIN_PASSWORD.get_secret_value()
116145
if not expected or not secrets.compare_digest(password, expected):
117146
return False
118147

@@ -127,7 +156,7 @@ async def authenticate(self, request: Request) -> bool:
127156
return request.session.get("authenticated", False)
128157

129158

130-
def setup_admin(app, engine, secret_key: str) -> Admin | None:
159+
def setup_admin(app: Starlette, engine: AsyncEngine, secret_key: str) -> Admin | None:
131160
"""Mount SQLAdmin at /admin, or nothing at all when no password is set."""
132161
if not get_settings().ADMIN_PASSWORD:
133162
logger.warning("admin_panel_disabled", reason="ADMIN_PASSWORD is not set")

apps/api/src/helprs/core/config.py

Lines changed: 36 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,45 +2,51 @@
22

33
import base64
44
from functools import lru_cache
5+
from typing import Literal
56

6-
from pydantic import field_validator, model_validator
7+
from cryptography.fernet import Fernet, InvalidToken
8+
from pydantic import SecretStr, field_validator, model_validator
79
from pydantic_settings import BaseSettings, SettingsConfigDict
810

911

1012
class Settings(BaseSettings):
13+
"""Runtime configuration.
14+
15+
Every credential is a ``SecretStr`` so it cannot be rendered by accident.
16+
``repr(Settings())`` used to print all of them in cleartext, and
17+
``sentry_sdk.init`` defaults to ``include_local_variables=True`` -- a live
18+
``settings`` local sits in a dozen frames that can raise, so one unhandled
19+
500 was enough to ship the whole secret set to a third party. Read a value
20+
with ``.get_secret_value()``; ``SecretStr`` defines ``__len__``, so plain
21+
truthiness checks still work.
22+
"""
23+
1124
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
1225

1326
# Database
14-
DATABASE_URL: str
27+
DATABASE_URL: SecretStr
1528

1629
# Security
17-
SECRET_KEY: str
18-
FERNET_KEY: str
19-
ADMIN_PASSWORD: str = ""
30+
SECRET_KEY: SecretStr
31+
FERNET_KEY: SecretStr
32+
ADMIN_PASSWORD: SecretStr = SecretStr("")
2033

2134
# GitHub App
2235
GITHUB_APP_ID: str
23-
GITHUB_APP_PRIVATE_KEY: str = ""
36+
GITHUB_APP_PRIVATE_KEY: SecretStr = SecretStr("")
2437
GITHUB_CLIENT_ID: str = ""
25-
GITHUB_CLIENT_SECRET: str = ""
26-
GITHUB_WEBHOOK_SECRET: str = ""
27-
28-
# Anthropic
29-
ANTHROPIC_API_KEY: str = ""
30-
31-
# Lemon Squeezy
32-
LEMONSQUEEZY_API_KEY: str = ""
33-
LEMONSQUEEZY_WEBHOOK_SECRET: str = ""
38+
GITHUB_CLIENT_SECRET: SecretStr = SecretStr("")
39+
GITHUB_WEBHOOK_SECRET: SecretStr = SecretStr("")
3440

3541
# Sentry
36-
SENTRY_DSN: str = ""
42+
SENTRY_DSN: SecretStr = SecretStr("")
3743

3844
# CORS
3945
CORS_ORIGINS: list[str] = ["http://localhost:5173"]
4046

4147
# Frontend base URL used to build user-facing links (e.g. PR-comment session links).
4248
# Kept separate from CORS_ORIGINS: that list is for browser security, this single
43-
# string is for link composition. See deferred-work.md nice-to-have #11.
49+
# string is for link composition.
4450
APP_BASE_URL: str = "http://localhost:5173"
4551

4652
# Container orchestration
@@ -51,25 +57,27 @@ class Settings(BaseSettings):
5157
DB_POOL_SIZE: int = 10
5258
DB_MAX_OVERFLOW: int = 5
5359

54-
# Environment
55-
ENVIRONMENT: str = "development"
60+
# Environment. A Literal rather than a free-form str: validate_production_secrets
61+
# below only fires on the exact string "production", so a typo like "prod" would
62+
# silently disable the one guard meant to fail loud at startup.
63+
ENVIRONMENT: Literal["development", "test", "production"] = "development"
5664

5765
@field_validator("GITHUB_APP_PRIVATE_KEY")
5866
@classmethod
59-
def normalize_private_key(cls, v: str) -> str:
67+
def normalize_private_key(cls, v: SecretStr) -> SecretStr:
6068
"""Accept the App private key as raw PEM or base64-encoded PEM.
6169
6270
``.env`` files cannot hold multi-line values, so operators base64 the
6371
PEM; platforms with multi-line env vars (Coolify) paste it raw. Both
6472
are normalized to raw PEM here so the rest of the code only ever sees
6573
what ``jwt.encode`` expects.
6674
"""
67-
v = v.strip()
68-
if not v or v.startswith("-----BEGIN"):
69-
return v
75+
raw = v.get_secret_value().strip()
76+
if not raw or raw.startswith("-----BEGIN"):
77+
return SecretStr(raw)
7078

7179
try:
72-
decoded = base64.b64decode(v, validate=True).decode()
80+
decoded = base64.b64decode(raw, validate=True).decode()
7381
except (ValueError, UnicodeDecodeError) as e:
7482
raise ValueError(
7583
"GITHUB_APP_PRIVATE_KEY must be a PEM private key, either raw "
@@ -79,16 +87,14 @@ def normalize_private_key(cls, v: str) -> str:
7987

8088
if not decoded.lstrip().startswith("-----BEGIN"):
8189
raise ValueError("GITHUB_APP_PRIVATE_KEY decoded from base64 but is not a PEM private key")
82-
return decoded
90+
return SecretStr(decoded)
8391

8492
@field_validator("FERNET_KEY")
8593
@classmethod
86-
def validate_fernet_key(cls, v: str) -> str:
94+
def validate_fernet_key(cls, v: SecretStr) -> SecretStr:
8795
"""Validate that FERNET_KEY is a valid Fernet key (32 url-safe base64 bytes)."""
88-
from cryptography.fernet import Fernet, InvalidToken
89-
9096
try:
91-
Fernet(v.encode() if isinstance(v, str) else v)
97+
Fernet(v.get_secret_value().encode())
9298
except (ValueError, InvalidToken) as e:
9399
raise ValueError(
94100
"FERNET_KEY must be a valid Fernet key (32 url-safe base64-encoded bytes). "
@@ -105,7 +111,7 @@ def validate_production_secrets(self) -> "Settings":
105111
missing: list[str] = []
106112
if not self.ADMIN_PASSWORD:
107113
missing.append("ADMIN_PASSWORD")
108-
if len(self.SECRET_KEY) < 32:
114+
if len(self.SECRET_KEY.get_secret_value()) < 32:
109115
missing.append("SECRET_KEY (must be >= 32 characters)")
110116
if not self.GITHUB_WEBHOOK_SECRET:
111117
missing.append("GITHUB_WEBHOOK_SECRET")

apps/api/src/helprs/core/database.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ def create_engine() -> AsyncEngine:
101101
"""
102102
settings = get_settings()
103103
return create_async_engine(
104-
settings.DATABASE_URL,
104+
settings.DATABASE_URL.get_secret_value(),
105105
echo=False,
106106
pool_pre_ping=True,
107107
pool_size=settings.DB_POOL_SIZE,

apps/api/src/helprs/core/dependencies.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ async def get_db(request: Request) -> AsyncGenerator[AsyncSession, None]:
3636
async def _authenticate(request: Request, session: AsyncSession, settings: Settings, token: str) -> GitHubUser:
3737
"""Resolve a bearer token to the user it belongs to."""
3838
try:
39-
payload = decode_access_token(token, settings.SECRET_KEY)
39+
payload = decode_access_token(token, settings.SECRET_KEY.get_secret_value())
4040
except JWTError as e:
4141
raise UnauthorizedError("Invalid or expired token") from e
4242

apps/api/src/helprs/core/middleware.py

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def configure_sentry(settings: Settings) -> None:
4747
"""Initialize Sentry SDK if SENTRY_DSN is configured."""
4848
if settings.SENTRY_DSN:
4949
sentry_sdk.init(
50-
dsn=settings.SENTRY_DSN,
50+
dsn=settings.SENTRY_DSN.get_secret_value(),
5151
traces_sample_rate=0.2,
5252
integrations=[
5353
StarletteIntegration(transaction_style="endpoint"),
@@ -89,20 +89,40 @@ async def dispatch(self, request: Request, call_next) -> Response:
8989

9090

9191
def setup_middleware(app: FastAPI, settings: Settings) -> None:
92-
"""Register all middleware on the app in correct order."""
93-
# CORS first
92+
"""Register all middleware on the app.
93+
94+
Registration order is the REVERSE of execution order: Starlette inserts
95+
each new middleware at the front of the stack, so the last one added ends
96+
up outermost. Rate limiting is therefore registered first and CORS last.
97+
98+
CORS outermost is the documented convention and the safe default: any
99+
response produced below it then carries the headers. Note this is
100+
belt-and-braces rather than a bug fix -- rate-limit rejections already
101+
reach the browser correctly, because RateLimitExceeded subclasses
102+
HTTPException and is rendered by ExceptionMiddleware, which sits inside
103+
the user stack either way. The genuinely broken case, unhandled 500s, is
104+
unreachable from here at all: ServerErrorMiddleware always sits outside
105+
the user stack, which is why ``_handle_unhandled_exception`` has to set
106+
the headers by hand.
107+
"""
108+
# Innermost: rate limiting.
109+
app.state.limiter = limiter
110+
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
111+
app.add_middleware(SlowAPIMiddleware)
112+
113+
# Then request logging, so rate-limited requests get logged too.
114+
app.add_middleware(RequestLoggingMiddleware)
115+
116+
# Outermost: CORS, so every response carries the headers -- including the
117+
# ones short-circuited by the middleware below it.
94118
app.add_middleware(
95119
CORSMiddleware,
96120
allow_origins=settings.CORS_ORIGINS,
97121
allow_credentials=True,
98122
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
99123
allow_headers=["Authorization", "Content-Type", "X-Request-ID", "Last-Event-ID"],
124+
# Set on the way out; allow_headers only governs the request
125+
# direction, so without this the frontend cannot read the ID it would
126+
# quote in a bug report.
127+
expose_headers=["X-Request-ID"],
100128
)
101-
102-
# Request logging
103-
app.add_middleware(RequestLoggingMiddleware)
104-
105-
# Rate limiting
106-
app.state.limiter = limiter
107-
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
108-
app.add_middleware(SlowAPIMiddleware)

apps/api/src/helprs/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
183183
set_session_factory(session_factory)
184184
stack.callback(clear_session_factory)
185185

186-
setup_admin(app, engine, settings.SECRET_KEY)
186+
setup_admin(app, engine, settings.SECRET_KEY.get_secret_value())
187187

188188
await _replay_pending_webhook_events(app)
189189
await _reconcile_stale_sessions(session_factory)

apps/api/src/helprs/modules/container/docker_client.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212

1313
CONTAINER_MEMORY_BYTES = 512 * 1024 * 1024
1414
CONTAINER_NANO_CPUS = 1_000_000_000
15+
# Generous for a git clone plus a node process, tight enough that a runaway
16+
# fork loop hits the cap instead of the host's process table.
17+
CONTAINER_PIDS_LIMIT = 512
1518

1619
# The entrypoint reads newline-terminated commands from this FIFO.
1720
_INPUT_FIFO = "/tmp/claude-input"
@@ -75,11 +78,19 @@ async def create_container(
7578
"Env": [f"{k}={v}" for k, v in environment.items()],
7679
"Labels": labels,
7780
"OpenStdin": True,
81+
# This is the one container in the system that executes
82+
# untrusted input: `claude --dangerously-skip-permissions`
83+
# against PR content written by whoever opened the PR. Memory
84+
# and CPU alone do not bound that -- a fork bomb exhausts PIDs
85+
# long before it exhausts a 512 MB cap.
7886
"HostConfig": {
7987
"Binds": volumes,
8088
"Memory": CONTAINER_MEMORY_BYTES,
8189
"NanoCPUs": CONTAINER_NANO_CPUS,
8290
"NetworkMode": "bridge",
91+
"PidsLimit": CONTAINER_PIDS_LIMIT,
92+
"CapDrop": ["ALL"],
93+
"SecurityOpt": ["no-new-privileges"],
8394
},
8495
}
8596
)

0 commit comments

Comments
 (0)