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
27 changes: 27 additions & 0 deletions backend/app/adapters/solr.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,26 @@ def _coerce_to_solr_param_value(value: Any) -> Any:
return value


def _neutralize_leading_local_params(q: str) -> str:
"""Escape a leading Solr local-params prefix on the main ``q`` param.

Security audit 2026-07-11 finding #6: the render path substitutes the
untrusted ``query_text`` into ``q``. Solr honors a leading ``{!parser ...}``
local-params block on ``q`` to switch the query parser (``{!func}``,
``{!join ...}``, etc.) regardless of ``defType``, so an injected prefix in
the user query could change query semantics. Solr only interprets the block
at the very start of the value, so escaping the leading ``{`` makes Solr
treat it as a literal term while leaving the rest of the query intact. Only
``q`` is sanitized this way — ``rq`` (LTR rerank) is system-generated and
legitimately uses local params.
"""
stripped = q.lstrip()
if stripped.startswith("{!"):
idx = len(q) - len(stripped) # preserve any leading whitespace
return q[:idx] + "\\" + q[idx:]
return q


class ProbeResult(BaseModel):
"""Capability probe output.

Expand Down Expand Up @@ -1090,6 +1110,13 @@ def render(
self._check_ltr_model_available(rerank)

body = self._pivot_to_solr_params(rendered)
# Neutralize a leading local-params block on the user-controlled `q`
# (security audit 2026-07-11 finding #6). Only the render path (user
# query_text) reaches here; the operator run_query passthrough bypasses
# render, and system-generated `rq` local params are left untouched.
q_val = body.get("q")
if isinstance(q_val, str):
body["q"] = _neutralize_leading_local_params(q_val)
return NativeQuery(query_id=template.name, body=body)

def _check_ltr_model_available(self, rerank: dict[str, Any]) -> None:
Expand Down
46 changes: 42 additions & 4 deletions backend/app/agent/confirmation.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@


# Single-word and short-phrase affirmatives. Whole-word matching against the
# single-word tokens; substring presence is sufficient for the short phrases.
# single-word tokens; the short phrases match only when they LEAD the message
# (see ``_LEADING_AFFIRMATIVE_PHRASE``) so a mid-sentence occurrence in a
# non-authorizing reply does not count.
_AFFIRMATIVE_TOKENS: frozenset[str] = frozenset(
{
"yes",
Expand All @@ -56,6 +58,17 @@
"ship it",
)

#: Regex matching an affirmative phrase only when it *leads* the message (after
#: optional leading punctuation/whitespace). Anchoring kills the false positive
#: where a phrase appears mid-sentence in a non-authorizing reply — e.g.
#: "maybe do it tomorrow" or "I'll do it later" contain "do it" as a substring
#: but are not authorizations. A leading "go ahead" / "do it" / "ship it" is a
#: genuine go-ahead. Built from :data:`_AFFIRMATIVE_PHRASES` so the two never drift.
_LEADING_AFFIRMATIVE_PHRASE = re.compile(
r"^\W*(?:" + "|".join(re.escape(p) for p in _AFFIRMATIVE_PHRASES) + r")\b",
re.IGNORECASE,
)


# Negation tokens that, if present, disqualify the message from being
# treated as affirmative — even if it also contains an affirmative token
Expand All @@ -77,9 +90,29 @@
"wait",
"nope",
"never",
# Deferral / inability tokens — a reply that contains these is not an
# authorization even if it also carries an affirmative token ("ok" in
# "ok thanks", "do it" in "do it later"). These are whole-word matched
# against the [a-z]+ token set, so they never fire inside a larger word
# (e.g. "cant" does NOT match "significant"). The apostrophe contraction
# forms "can't" / "couldn't" are handled separately in is_affirmative
# (the [a-z]+ split turns them into "can"+"t" / "couldn"+"t", so bare
# "can"/"couldn" are intentionally NOT here — they would false-reject
# valid affirmatives like "yes you can" / "I can confirm").
"cant",
"cannot",
"couldnt",
"maybe",
"later",
Comment on lines +93 to +106

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Including "can" and "couldn" in _NEGATION_TOKENS causes false negatives for common, valid affirmative responses such as "yes you can" or "I can confirm". Since the [a-z]+ regex splits contractions like "can't" and "couldn't" into separate words, we should remove "can" and "couldn" from the token set and instead handle these contractions explicitly in is_affirmative.

Suggested change
# Deferral / inability tokens — a reply that contains these is not an
# authorization even if it also carries an affirmative token ("ok" in
# "ok thanks", "do it" in "can't do it right now" / "do it later").
"can", # can't (apostrophe stripped by the [a-z] regex) / "I can later"
"cant",
"cannot",
"couldn", # couldn't
"couldnt",
"maybe",
"later",
# Deferral / inability tokens — a reply that contains these is not an
# authorization even if it also carries an affirmative token ("ok" in
# "ok thanks", "do it" in "can't do it right now" / "do it later").
"cant",
"cannot",
"couldnt",
"maybe",
"later",

}
)

#: Apostrophe-form inability contractions. Checked as substrings of the
#: lowercased (apostrophe-preserving) text because the ``[a-z]+`` tokenizer
#: splits them; the apostrophe makes the substring match safe (no common word
#: contains "can't" / "couldn't").
_NEGATION_CONTRACTIONS: tuple[str, ...] = ("can't", "couldn't")


def is_affirmative(text: str) -> bool:
"""Return ``True`` if ``text`` reads as user affirmation of a mutating action.
Expand All @@ -94,10 +127,15 @@ def is_affirmative(text: str) -> bool:
if not text:
return False
lowered = text.lower()
if any(neg in lowered for neg in _NEGATION_CONTRACTIONS):
return False
words = set(re.findall(r"[a-z]+", lowered))
if _NEGATION_TOKENS & words:
return False
for phrase in _AFFIRMATIVE_PHRASES:
if phrase in lowered:
return True
# Affirmative *phrases* must LEAD the message (anchored) so a mid-sentence
# "do it" in "I'll do it later" doesn't unlock dispatch — the leading
# negation/deferral tokens above already reject most of those, this closes
# the residual where no negation token is present.
if _LEADING_AFFIRMATIVE_PHRASE.match(lowered):
return True
Comment on lines +135 to +140

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To support removing "can" and "couldn" from _NEGATION_TOKENS (preventing false negatives on valid confirmations), we can explicitly check for "can't" and "couldn't" contractions in is_affirmative before evaluating other rules.

Suggested change
# Affirmative *phrases* must LEAD the message (anchored) so a mid-sentence
# "do it" in "I'll do it later" doesn't unlock dispatch — the leading
# negation/deferral tokens above already reject most of those, this closes
# the residual where no negation token is present.
if _LEADING_AFFIRMATIVE_PHRASE.match(lowered):
return True
# Since the [a-z]+ regex splits "can't" into "can" and "t", we check for "can't" / "couldn't" explicitly
# to avoid false negatives when "can" is used in a valid affirmative (e.g., "yes you can").
if any(neg in lowered for neg in ("can't", "cant", "cannot", "couldn't", "couldnt")):
return False
# Affirmative *phrases* must LEAD the message (anchored) so a mid-sentence
# "do it" in "I'll do it later" doesn't unlock dispatch — the leading
# negation/deferral tokens above already reject most of those, this closes
# the residual where no negation token is present.
if _LEADING_AFFIRMATIVE_PHRASE.match(lowered):
return True

return bool(_AFFIRMATIVE_TOKENS & words)
18 changes: 17 additions & 1 deletion backend/app/agent/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
TOOLS,
)
from backend.app.core.logging import get_logger
from backend.app.llm.budget_gate import record_cost
from backend.app.llm.budget_gate import peek_daily_total, record_cost
from backend.app.llm.capability_check import read_capability_result
from backend.app.llm.cost_model import compute_call_cost

Expand Down Expand Up @@ -232,6 +232,22 @@ async def run_turn(
while iterations < MAX_LOOP_ITERATIONS:
iterations += 1

# 2a-pre. Per-iteration budget gate (security audit 2026-07-11 finding
# #8). The endpoint peeks the daily budget once BEFORE the turn, but the
# tool loop can issue up to MAX_LOOP_ITERATIONS full-history completions
# within a single turn — a turn that starts just under the cap could
# overshoot it. Re-peek before each completion so the running total is
# enforced mid-loop, matching the worker LLM paths.
if ctx.settings.openai_daily_budget_usd > 0:
current_total = await peek_daily_total(ctx.redis)
if current_total >= ctx.settings.openai_daily_budget_usd:
yield DoneEvent(
conversation_id=conversation_id,
error="openai_budget_exceeded",
iterations=iterations,
)
return

# 2a. Call OpenAI with streaming + usage.
kwargs: dict[str, Any] = {
"model": ctx.settings.openai_model_chat,
Expand Down
13 changes: 11 additions & 2 deletions backend/app/api/v1/schemas/proposals.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,17 @@ class CreateConfigRepoRequest(BaseModel):

name: str = Field(min_length=1, max_length=128, pattern=r"^[a-z0-9][a-z0-9-]*$")
repo_url: str = Field(min_length=1, max_length=512)
default_branch: str = Field(default="main", min_length=1, max_length=128)
pr_base_branch: str = Field(default="main", min_length=1, max_length=128)
# Git-ref-safe pattern (security audit 2026-07-11 finding #7): must start
# with an alphanumeric so a value can never begin with '-' and be parsed by
# git as an option (the classic `--upload-pack=...` argument-injection
# shape); restrict to the safe branch-name charset. These fields flow as
# positional git arguments in the open_pr worker.
default_branch: str = Field(
default="main", min_length=1, max_length=128, pattern=r"^[A-Za-z0-9][A-Za-z0-9._/-]*$"
)
pr_base_branch: str = Field(
default="main", min_length=1, max_length=128, pattern=r"^[A-Za-z0-9][A-Za-z0-9._/-]*$"
)
auth_ref: str = Field(min_length=1, max_length=128, pattern=r"^[a-zA-Z0-9_-]+$")
webhook_secret_ref: str | None = Field(
default=None,
Expand Down
48 changes: 48 additions & 0 deletions backend/app/domain/study/template_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,20 @@ class InvalidTemplateSyntax(ValueError):
"""


class UnsafeQueryTextInterpolation(InvalidTemplateSyntax):
"""``query_text`` is interpolated without a ``| tojson`` filter.

``query_text`` carries untrusted user query text and is substituted into a
JSON query-DSL document at render time. Interpolating it raw (e.g.
``"query": "{{ query_text }}"``) lets a query containing a ``"`` break out
of its JSON string and inject arbitrary query-DSL keys (and, at minimum,
raise ``JSONDecodeError``). ``| tojson`` emits a correctly-escaped,
self-quoted JSON string literal, closing the injection. Subclasses
:exc:`InvalidTemplateSyntax` so it maps to the same 400
``INVALID_TEMPLATE_SYNTAX`` router response.
"""


class UndeclaredParamUsed(ValueError):
"""Template body references a param not in ``declared_params``.

Expand Down Expand Up @@ -78,6 +92,30 @@ class ReservedParamReferenced(ValueError):
authors do NOT need to declare it."""


def _assert_query_text_is_tojson_escaped(ast: nodes.Template) -> None:
"""Reject any ``query_text`` reference not wrapped by a ``tojson`` filter.

A ``query_text`` Name node is considered safe iff it is a descendant of a
``Filter`` node whose name is ``tojson`` (so ``{{ query_text | tojson }}``
and ``{{ query_text | trim | tojson }}`` both pass, while
``{{ query_text }}`` and ``{{ query_text | upper }}`` are rejected).
"""
covered: set[int] = set()
for filt in ast.find_all(nodes.Filter):
if filt.name == "tojson":
for name_node in filt.find_all(nodes.Name):
covered.add(id(name_node))

for name_node in ast.find_all(nodes.Name):
if name_node.name == "query_text" and id(name_node) not in covered:
raise UnsafeQueryTextInterpolation(
"query_text must be interpolated through the `| tojson` filter "
"(e.g. `{{ query_text | tojson }}`) so untrusted query text is "
"JSON-escaped and cannot inject query-DSL; found a raw "
"`query_text` reference"
)


def validate_template_body(body: str, declared_params: dict[str, str]) -> None:
"""Validate a Jinja2 template body against the declared param set.

Expand Down Expand Up @@ -131,6 +169,16 @@ def validate_template_body(body: str, declared_params: dict[str, str]) -> None:
"prefixed identifiers in query templates"
)

# Step 2c — require query_text to flow through `| tojson` (SSTI/query-DSL
# injection guard). query_text is always untrusted user input substituted
# into a JSON query-DSL document; a raw `{{ query_text }}` inside a JSON
# string lets a `"` break out and inject keys. `| tojson` emits a
# correctly-escaped self-quoted string, so we reject any query_text
# reference not covered by a tojson filter. (Numeric search-space params
# like boosts are intentionally exempt — tojson would strip the quotes they
# need inside string literals like "title^{{ title_boost }}".)
_assert_query_text_is_tojson_escaped(ast)

# Step 3 — declared / undeclared cross-check.
referenced: set[str] = meta.find_undeclared_variables(ast)
declared: set[str] = set(declared_params) | _IMPLICIT_PARAMS
Expand Down
44 changes: 43 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,34 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
event_type="github_token_file_deprecated",
)

# Hardened-posture guards (security audit 2026-07-11 findings #4/#5).
# ENVIRONMENT defaults to "development", which UN-gates the destructive,
# unauthenticated /api/v1/_test/* endpoints (hard-DELETE + demo reseed).
# That is correct for a laptop/CI host but a footgun if such an instance is
# ever exposed to an untrusted network before the auth surface lands, so we
# emit a one-line boot reminder.
if settings.environment == "development":
logger.warning(
"ENVIRONMENT=development: destructive, unauthenticated /api/v1/_test/* "
"endpoints (hard-delete + demo reseed) are ENABLED. Only run this "
"instance on a trusted local/CI host — never expose it to an untrusted "
"network. Set ENVIRONMENT=staging|production to disable them.",
event_type="dev_test_endpoints_enabled",
)

# The cluster base_url SSRF guard is a no-op while RELYLOOP_ALLOW_PRIVATE_
# CLUSTERS is True (the laptop-friendly default). On a non-development
# deployment that default means ZERO SSRF protection (internal hosts + cloud
# metadata IPs are registerable), so warn loudly to flip it.
if settings.relyloop_allow_private_clusters and settings.environment != "development":
logger.warning(
"RELYLOOP_ALLOW_PRIVATE_CLUSTERS=True on a non-development deployment "
f"(ENVIRONMENT={settings.environment}): the cluster base_url SSRF guard "
"is disabled, so internal/metadata endpoints can be registered and "
"probed. Set RELYLOOP_ALLOW_PRIVATE_CLUSTERS=False to enable the guard.",
event_type="ssrf_guard_disabled_non_dev",
)
Comment on lines +112 to +118

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using %s formatting with structlog loggers is discouraged and may not format correctly depending on the processor configuration. It is cleaner and safer to use an f-string or pass the environment as a structured keyword argument.

        logger.warning(
            f"RELYLOOP_ALLOW_PRIVATE_CLUSTERS=True on a non-development deployment "
            f"(ENVIRONMENT={settings.environment}): the cluster base_url SSRF guard is disabled, so "
            f"internal/metadata endpoints can be registered and probed. Set "
            f"RELYLOOP_ALLOW_PRIVATE_CLUSTERS=False to enable the guard.",
            event_type="ssrf_guard_disabled_non_dev",
        )


redis_client: Redis = Redis.from_url(settings.redis_url, decode_responses=False)
cap_task = asyncio.create_task(
run_capability_check_background(
Expand Down Expand Up @@ -193,11 +221,25 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
# (comma-separated). Empty string disables. MVP1 default covers the local Next
# dev server; operators add production origins at MVP3.
_cors_origins = [o.strip() for o in get_settings().cors_allow_origins.split(",") if o.strip()]
# Security audit 2026-07-11 finding #10: a wildcard origin combined with
# allow_credentials=True is unsafe — Starlette reflects the request Origin
# (rather than sending a literal "*"), which lets ANY site make credentialed
# cross-origin requests. Disable credentials whenever a wildcard is configured;
# MVP1 has no cookies/auth so nothing depends on credentialed CORS today.
_cors_has_wildcard = "*" in _cors_origins
_cors_allow_credentials = not _cors_has_wildcard
if _cors_has_wildcard:
logger.warning(
"CORS_ALLOW_ORIGINS contains '*'; disabling allow_credentials to avoid "
"reflecting arbitrary origins with credentials. Configure explicit "
"origins if you need credentialed CORS.",
event_type="cors_wildcard_credentials_disabled",
)
if _cors_origins:
app.add_middleware(
CORSMiddleware,
allow_origins=_cors_origins,
allow_credentials=True,
allow_credentials=_cors_allow_credentials,
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
# Browsers ALWAYS strip custom headers from the request preflight unless
# they're explicitly allowed. The UI's api-client.ts injects X-Request-ID
Expand Down
33 changes: 24 additions & 9 deletions backend/app/services/agent_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,16 +233,31 @@ async def send_user_message(
# Compute confirmation-guard inputs from the persisted history (BEFORE the
# just-inserted user message — the orchestrator gets the user_text as a
# parameter separately).
#
# The confirmation guard binds a user affirmative to a proposal ONLY from
# the *immediately-preceding* assistant turn. The earlier implementation
# scanned all prior turns and kept the last non-empty assistant text, which
# let a fresh "yes" authorize a STALE proposal from several turns back when
# every intervening assistant turn had empty text (a bare tool-call turn).
# Binding to the single message that directly precedes the current user
# message closes that carry-over: if that message is not a non-empty
# assistant proposal (it's a bare tool-call turn, a tool result, or an
# older exchange), ``last_assistant_text`` stays None and the guard fails
# safe (the model must re-propose).
prior_messages = all_messages[:-1] # exclude the user message we just inserted
last_assistant_text: str | None = None
degraded_notice_already_sent = False
for m in all_messages[:-1]: # exclude the user message we just inserted
if m.role == "assistant":
content_field = m.content or {}
text = content_field.get("text")
if isinstance(text, str) and text.strip():
last_assistant_text = text
if content_field.get("kind") == "system_notice":
degraded_notice_already_sent = True
if prior_messages:
prev = prior_messages[-1]
if prev.role == "assistant":
prev_text = (prev.content or {}).get("text")
if isinstance(prev_text, str) and prev_text.strip():
last_assistant_text = prev_text
# Degraded-notice detection is independent of the confirmation-binding
# window — a system_notice sent at any earlier point should not be resent.
degraded_notice_already_sent = any(
m.role == "assistant" and (m.content or {}).get("kind") == "system_notice"
for m in prior_messages
)

# 4. Build orchestrator deps + run the turn.
ctx = ToolContext(
Expand Down
27 changes: 27 additions & 0 deletions backend/app/services/cluster.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,9 @@ async def reprobe_cluster(db: AsyncSession, redis: Redis, cluster_id: str) -> Cl
if cluster is None:
raise ClusterNotFound(cluster_id)

# SSRF re-validation on the reuse path (see acquire_adapter). No-op unless
# RELYLOOP_ALLOW_PRIVATE_CLUSTERS is False.
await assert_base_url_allowed(cluster.base_url)
try:
adapter = build_adapter(cluster)
except CredentialsMissing as exc:
Expand Down Expand Up @@ -386,6 +389,20 @@ async def get_or_probe_health(redis: Redis, cluster: Cluster) -> HealthStatus:
cached = await read_cached_health(redis, cluster.id)
if cached is not None:
return cached
# SSRF re-validation on the reuse path (see acquire_adapter). A rebound host
# is surfaced as a cached ``unreachable`` health rather than a 500 so the
# /healthz aggregate (cache-only per Absolute Rule #11) degrades cleanly.
# No-op unless RELYLOOP_ALLOW_PRIVATE_CLUSTERS is False.
try:
await assert_base_url_allowed(cluster.base_url)
except ClusterUrlBlocked as exc:
health = HealthStatus(
status="unreachable",
checked_at=datetime.now(UTC).isoformat(),
error=f"cluster base_url blocked by SSRF policy: {exc}",
)
await write_cached_health(redis, cluster.id, health)
return health
try:
adapter = build_adapter(cluster)
except CredentialsMissing as exc:
Expand Down Expand Up @@ -436,6 +453,16 @@ async def acquire_adapter(cluster: Cluster) -> AsyncIterator[ClusterAdapter]:
except TargetNotFoundError as exc:
raise _err(404, "TARGET_NOT_FOUND", ...) from exc
"""
# SSRF re-validation on the reuse path (bug_cluster_url_ssrf_hostname_bypass
# Phase 2 — DNS-rebinding TOCTOU mitigation). The registration-time guard
# classified the *then*-current DNS resolution and stored an immutable
# base_url; re-running it here re-resolves + re-classifies on every adapter
# build, so a host that pointed at a public IP at registration but was later
# rebound to an internal/metadata address is caught before we connect.
# No-op unless RELYLOOP_ALLOW_PRIVATE_CLUSTERS is False. Does not defeat a
# within-single-connection rebind — full connect-time IP pinning remains the
# residual Phase-2 item.
await assert_base_url_allowed(cluster.base_url)
try:
adapter = build_adapter(cluster)
except CredentialsMissing as exc:
Expand Down
Loading
Loading