fix(runs): reject cancel actions on GET stream joins (#5092)

* fix(runs): reject cancel actions on GET stream joins

stream_existing_run is registered for both GET and POST, and its
?action=interrupt|rollback branch cancels the run. The CSRF middleware
exempts GET, so a session-authenticated browser could be forced
cross-site (img/script/top-level navigation) into
GET /api/threads/{id}/runs/{run_id}/stream?action=interrupt|rollback —
a state-changing GET that bypasses the CSRF protection guarding the
POST variant. Introduced with the dual registration in #1403.

The handler's docstring already documents cancel-then-stream as
POST-only (the LangGraph SDK's joinStream/useStream stop button uses
POST); enforce it: GET with an action answers 405, action-less GET
joins and POST cancel-then-stream are unchanged.

Regression drives the real router: GET+action is 405 with the run left
running, plain GET join still streams, POST+action still cancels.

* fix(runs): scope the 405 detail to the action requirement

"GET is a read-only stream join" overstates the current main: on a
locally-owned run with the default on_disconnect=cancel, a GET join's
disconnect can still trigger cancellation. That observer-disconnect
vector is closed by #5041; the detail here should only claim what this
guard enforces.

* fix(runs): harden GET stream action rejection

* fix(runs): align stream schema with method contract

* test(runs): pin GET stream action 405 through the production stack

Review follow-up (defence-in-depth): the GET-action suite drove bare
FastAPI() apps, so nothing pinned that a session-authenticated
cross-site GET reaches the route gate at all once CSRF exempts the
safe method. test_pat_auth.py already assembles the production
middleware order (AuthMiddleware inner, CSRFMiddleware outer), so its
mirror app now registers the real _reject_get_stream_action
dependency on a GET join route.

The new case pins the end-to-end premise: an authenticated GET
?action=interrupt is answered 405 + Allow: POST by the production
route dependency, while the same unauthenticated GET dies at
AuthMiddleware's 401 before any route logic runs.

Validation: focused suites (test_pat_auth, test_stream_get_action,
test_csrf_middleware) — 62 passed; ruff check + format clean; the new
case errors on the pre-fix baseline (guard absent), confirming the
pin.
This commit is contained in:
Sunshine 2026-09-01 15:51:37 +08:00 committed by GitHub
parent df57f8e269
commit a956bbc030
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 231 additions and 22 deletions

View File

@ -799,6 +799,14 @@ LangSmith and Langfuse attach as LangChain callbacks, so you can enable both and
For Docker deployments, tracing is disabled by default. Set `LANGSMITH_TRACING=true` and `LANGSMITH_API_KEY` in your `.env` to enable it.
#### Existing-Run Stream Actions
Existing-run SSE joins are observation-only on `GET`: supplying
`action=interrupt|rollback` returns `405`. Cancellation on this stream route is
`POST`-only and requires the `runs:cancel` permission. Accordingly, the OpenAPI
contract exposes `action` and `wait` only on `POST`; the `GET` operation exposes
only its path parameters.
#### Personal Access Tokens
Non-interactive clients (CI pipelines, scripts, server-to-server integrations)

View File

@ -57,7 +57,7 @@ owner-scoped assistant version selection remains enabled.
| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - stream regular text and binary artifacts with `FileResponse`, including byte-`Range` 206/416 behavior used by bounded text previews and media seeking; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types. `PUT /{path}` atomically replaces an existing UTF-8 text file under `/mnt/user-data/outputs` when its expected SHA-256 still matches; active runs conflict, and non-mounted sandbox providers receive the same update explicitly. Atomic replacement applies the existing POSIX permission handling when descriptor-based APIs are available and otherwise keeps the platform-native temporary-file permissions (Windows). |
| **Suggestions** (`/api/suggestions`) | `GET /config` - returns global suggestions config boolean; `POST /threads/{id}/suggestions` - generate follow-up questions; rich list/block model content is normalized and inline reasoning (`<think>...</think>`, including unclosed/truncated blocks from reasoning models like MiniMax-M3) is stripped before JSON parsing |
| **Input Polish** (`/api/input-polish`) | `POST /` - rewrite a composer draft before it is sent. This is a short authenticated `runs:create` LLM request using `input_polish` config; it does not create a LangGraph run, persist a message, or modify thread state. Shares the non-graph one-shot LLM path (`deerflow.utils.oneshot_llm.run_oneshot_llm`) with the suggestions route so model build + Langfuse metadata + invoke stay in one place; validates the same stripped view of the draft it sends to the model, and preserves literal `<think>` substrings in the rewrite (`strip_think_blocks(truncate_unclosed=False)`) |
| **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block. Before the first journaled run, an empty run-event message feed is seeded from an existing checkpoint head so legacy checkpoint-only history receives earlier thread-global sequence numbers and remains visible after the new run; a thread with no checkpoint or an already-populated feed skips this compatibility path. `POST /regenerate/prepare` - prepare clean input + checkpoint metadata for regenerating the latest completed or interrupted assistant answer, carrying the latest non-empty thread title in graph input so resuming an older checkpoint cannot roll back a later manual rename (#4457); `POST /edit-regenerate/prepare` - prepare a checkpoint replay from the latest editable human turn with a replacement user message and edit replay metadata; it carries the current thread title the same way, but only when the replay base already has one — an untitled base belongs to a thread the title middleware has not named yet, so pinning the current title there would keep a name generated from the prompt the edit just replaced; `GET /` - list runs; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/messages` - paginated per-run messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /{rid}/workspace-changes` - workspace/output file change summary and optional diffs; `GET /../messages` - legacy thread message array; `GET /../messages/page` - backward thread-global `seq` history page with middleware/subagent-AI/successful-regenerate/edit-replay filtering and page-run-scoped feedback enrichment; subagent AI callbacks remain available through run events while parent `task` ToolMessages stay visible for card restoration; `GET /../token-usage` - aggregate tokens plus an optional `context_usage` percentage. Context usage approximately counts messages from the latest materialized thread state through `build_thread_checkpoint_state_accessor`, so full and delta checkpoint modes expose the same input. The percentage uses the latest run's model and its configured `context_window`. |
| **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block. Before the first journaled run, seed an empty event feed from an existing checkpoint so legacy checkpoint-only history keeps earlier thread-global ordering and stays visible; skip without a checkpoint or when the feed is populated. `POST /regenerate/prepare` - prepare clean input + checkpoint metadata for regenerating the latest completed or interrupted assistant answer, carrying the latest non-empty thread title in graph input so resuming an older checkpoint cannot roll back a later manual rename (#4457); `POST /edit-regenerate/prepare` - prepare a checkpoint replay from the latest editable human turn with a replacement user message and edit replay metadata; it carries the current thread title the same way, but only when the replay base already has one — an untitled base belongs to a thread the title middleware has not named yet, so pinning the current title there would keep a name generated from the prompt the edit just replaced; `GET /` - list runs; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/stream` hides action/wait; GET action 405 pre-owner; POST needs `runs:cancel`; `GET /{rid}/messages` - paginated per-run messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /{rid}/workspace-changes` - workspace/output file change summary and optional diffs; `GET /../messages` - legacy thread message array; `GET /../messages/page` - backward thread-global `seq` history page with middleware/subagent-AI/successful-regenerate/edit-replay filtering and page-run-scoped feedback enrichment; subagent AI callbacks remain available through run events while parent `task` ToolMessages stay visible for card restoration; `GET /../token-usage` - aggregate tokens plus an optional `context_usage` percentage. Context usage approximately counts messages from the latest materialized thread state through `build_thread_checkpoint_state_accessor`, so full and delta checkpoint modes expose the same input. The percentage uses the latest run's model and its configured `context_window`. |
| **Feedback** (`/api/threads/{id}/runs/{rid}/feedback`) | `PUT /` - upsert feedback; `DELETE /` - delete user feedback; `POST /` - create feedback; `GET /` - list feedback; `GET /stats` - aggregate stats; `DELETE /{fid}` - delete specific |
| **Runs** (`/api/runs`) | `POST /stream`, `/wait` - stateless runs requiring `runs:create`; optional body `thread_id` is owner-checked. Scheduled-task create/update/resume/trigger also require `threads:write` plus `runs:create`. `GET /{rid}/messages`, `/feedback` - run messages/feedback |
| **GitHub Webhooks** (`/api/webhooks/github`) | `POST /` - receive GitHub App / repo webhook deliveries. Verifies `X-Hub-Signature-256` against `GITHUB_WEBHOOK_SECRET`; exempt from auth + CSRF because authenticity is enforced by HMAC. The route is fail-closed: mounted only when `GITHUB_WEBHOOK_SECRET` is set, or when explicit dev opt-in `DEER_FLOW_ALLOW_UNVERIFIED_GITHUB_WEBHOOKS=1` is set. Recognized events include `ping`, `issues`, `issue_comment`, `pull_request`, `pull_request_review`, and `pull_request_review_comment`; unknown events return 200 with `handled=false`. Fan-out runtime failures return 503, keeping the delivery recorded as failed for manual/API/scripted redelivery (GitHub does not automatically retry any failed delivery, 5xx included); permanent/non-retryable conditions such as `channels.github.enabled: false`, unknown events, malformed payloads, or unavailable channel service return 200 with a skipped/handled response. |

View File

@ -18,7 +18,7 @@ from copy import deepcopy
from datetime import UTC, datetime
from typing import Any, Literal
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import Response, StreamingResponse
from langchain_core.messages import BaseMessage
from pydantic import BaseModel, Field
@ -1019,26 +1019,34 @@ async def join_run(thread_id: ThreadId, run_id: str, request: Request) -> Stream
)
# Register GET and POST as separate routes so each method gets a unique OpenAPI
# operationId. ``api_route(methods=["GET", "POST"])`` shares one route registration
# across both methods, which makes FastAPI emit the same ``operationId`` twice and
# warn about a duplicate operation id during OpenAPI generation.
@router.get("/{thread_id}/runs/{run_id}/stream", response_model=None)
@router.post("/{thread_id}/runs/{run_id}/stream", response_model=None)
@require_permission("runs", "read", owner_check=True)
async def stream_existing_run(
def _reject_get_stream_action(
action: Literal["interrupt", "rollback"] | None = Query(default=None, include_in_schema=False),
) -> None:
"""Keep the GET join read-only before thread ownership or run lookup."""
if action is not None:
# SameSite=Lax still sends the session cookie on a cross-site top-level
# safe navigation. Reject the state-changing action before the endpoint
# wrapper performs its thread ownership lookup.
raise HTTPException(
status_code=405,
detail="`action` is only supported on POST requests",
headers={"Allow": "POST"},
)
async def _stream_existing_run(
thread_id: ThreadId,
run_id: str,
request: Request,
action: Literal["interrupt", "rollback"] | None = Query(default=None, description="Cancel action"),
wait: int = Query(default=0, description="Block until cancelled (1) or return immediately (0)"),
):
"""Join an existing run's SSE stream (GET), or cancel-then-stream (POST).
*,
action: Literal["interrupt", "rollback"] | None,
wait: int,
) -> Response:
"""Join an existing run's SSE stream, optionally cancelling it first.
The LangGraph SDK's ``joinStream`` and ``useStream`` stop button both use
``POST`` to this endpoint. When ``action=interrupt`` or ``action=rollback``
is present the run is cancelled first; the response then streams any
remaining buffered events so the client observes a clean shutdown.
When ``action=interrupt`` or ``action=rollback`` is present the run is
cancelled first; the response then streams any remaining buffered events
so the client observes a clean shutdown.
"""
require_cancel_permission_when_action(request, action)
@ -1099,6 +1107,34 @@ async def stream_existing_run(
)
# Register POST before GET to preserve the historical route precedence and
# Allow header, while separate signatures keep cancel-only parameters off the
# GET schema. The shared route name keeps generated operationIds stable.
@router.post("/{thread_id}/runs/{run_id}/stream", response_model=None, name="stream_existing_run")
@require_permission("runs", "read", owner_check=True)
async def stream_existing_run(
thread_id: ThreadId,
run_id: str,
request: Request,
action: Literal["interrupt", "rollback"] | None = Query(default=None, description="Cancel action"),
wait: int = Query(default=0, description="Block until cancelled (1) or return immediately (0)"),
) -> Response:
"""Join an existing run's SSE stream, optionally cancelling it first."""
return await _stream_existing_run(thread_id, run_id, request, action=action, wait=wait)
@router.get(
"/{thread_id}/runs/{run_id}/stream",
response_model=None,
dependencies=[Depends(_reject_get_stream_action)],
name="stream_existing_run",
)
@require_permission("runs", "read", owner_check=True)
async def join_existing_run_stream(thread_id: ThreadId, run_id: str, request: Request) -> Response:
"""Join an existing run's observation-only SSE stream."""
return await _stream_existing_run(thread_id, run_id, request, action=None, wait=0)
# ---------------------------------------------------------------------------
# Messages / Events / Token usage endpoints
# ---------------------------------------------------------------------------

View File

@ -77,3 +77,17 @@ def test_stream_existing_run_exposes_distinct_get_and_post(openapi_spec: dict) -
post_op_id = path_item["post"].get("operationId")
assert get_op_id and post_op_id, "Both GET and POST must have operationIds"
assert get_op_id != post_op_id, f"GET and POST share operationId {get_op_id!r}, which breaks OpenAPI codegen"
def test_stream_existing_run_exposes_method_specific_query_parameters(openapi_spec: dict) -> None:
"""Only POST advertises the cancel-then-stream query contract."""
path = "/api/threads/{thread_id}/runs/{run_id}/stream"
path_item = openapi_spec["paths"][path]
get_parameters = {(parameter["in"], parameter["name"]) for parameter in path_item["get"].get("parameters", [])}
post_parameters = {(parameter["in"], parameter["name"]) for parameter in path_item["post"].get("parameters", [])}
assert ("query", "action") not in get_parameters
assert ("query", "wait") not in get_parameters
assert ("query", "action") in post_parameters
assert ("query", "wait") in post_parameters

View File

@ -1,8 +1,9 @@
"""Integration tests for PAT authentication (#4849).
Covers credential precedence in AuthMiddleware, the CSRF boundary for
Bearer-authenticated requests, scope intersection, PAT management routes,
and the self-protection rules (a PAT may not manage PATs or auth state).
Bearer-authenticated requests and for the safe-method stream join (#5092),
scope intersection, PAT management routes, and the self-protection rules
(a PAT may not manage PATs or auth state).
"""
from __future__ import annotations
@ -12,7 +13,7 @@ from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
import pytest
from fastapi import FastAPI, Request
from fastapi import Depends, FastAPI, Request
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from starlette.testclient import TestClient
@ -115,6 +116,20 @@ def _make_pat_app(with_pat_repo: bool = True):
require_cancel_permission_when_action(request, action)
return {"ok": True}
# Mirrors the real GET-only join surface (thread_runs.py
# join_existing_run_stream): registers the production route dependency
# that rejects cancel actions before thread ownership or run lookup, so
# the guard is exercised through the production middleware order above.
from app.gateway.routers.thread_runs import _reject_get_stream_action
@app.get(
"/api/threads/{thread_id}/runs/{run_id}/stream",
dependencies=[Depends(_reject_get_stream_action)],
)
@require_permission("runs", "read")
async def join_stream(thread_id: str, run_id: str, request: Request):
return {"ok": True}
# Mirrors the real run-creation entrypoints (thread_runs.py / runs.py):
# runs:create at the decorator, plus the cancel-capability gate that
# start_run applies to mutating multitask strategies. RunCreateRequest is
@ -311,6 +326,26 @@ def test_auth_endpoint_origin_check_not_bypassed_by_bearer(client):
assert response.json()["detail"] == "Cross-site auth request denied."
def test_session_get_stream_action_dies_at_route_gate_not_csrf(client):
"""#5092 defence-in-depth, end-to-end through the production middleware
order: SameSite=Lax still attaches the session cookie to a cross-site
top-level GET, and CSRF exempts safe methods so the route gate is the
only thing standing between that navigation and a run cancel. An
authenticated GET with ?action=interrupt is answered 405 + Allow: POST by
the real _reject_get_stream_action dependency, while the same
unauthenticated GET dies at AuthMiddleware's 401 before any route logic
runs."""
_session_cookie(client)
denied = client.get("/api/threads/t1/runs/run-1/stream?action=interrupt")
assert denied.status_code == 405
assert denied.headers["allow"] == "POST"
assert denied.json()["detail"] == "`action` is only supported on POST requests"
client.cookies.clear()
unauthed = client.get("/api/threads/t1/runs/run-1/stream?action=interrupt")
assert unauthed.status_code == 401
# ── Management routes + self-protection (#4849 point 6) ───────────────────

View File

@ -104,7 +104,7 @@ def test_join_routes_wire_sse_consumer_as_observers():
from app.gateway.routers import thread_runs
thread_runs_source = inspect.getsource(thread_runs)
# join_run + stream_existing_run (GET and POST share one handler)
# join_run + the shared existing-run stream implementation
assert thread_runs_source.count("sse_consumer(bridge, record, request, run_mgr, apply_on_disconnect=False)") == 2
# stream_run — the creator's create-and-stream endpoint
assert thread_runs_source.count("sse_consumer(bridge, record, request, run_mgr),") == 1

View File

@ -0,0 +1,116 @@
"""GET on the join-stream route must not carry cancel actions.
The existing-run stream path supports both GET and POST; POST's ``action``
branch cancels the run. The CSRF middleware exempts GET, while a SameSite=Lax
session cookie still accompanies a cross-site top-level safe navigation. An
attacker-induced navigation to
``GET .../runs/{run_id}/stream?action=interrupt|rollback`` was therefore a
state-changing GET that bypassed the CSRF protection guarding the POST
variant. These tests pin that GET stays a read-only join and POST keeps
cancelling.
"""
from __future__ import annotations
import asyncio
import pytest
from _router_auth_helpers import make_authed_test_app
from fastapi.testclient import TestClient
from app.gateway.routers import thread_runs
from deerflow.runtime import RunManager, RunStatus
from deerflow.runtime.stream_bridge import MemoryStreamBridge
THREAD_ID = "thread-get-action"
def _make_seeded_run_client(run_status: RunStatus = RunStatus.running) -> tuple[TestClient, RunManager, str]:
mgr = RunManager()
async def _seed():
record = await mgr.create(THREAD_ID)
await mgr.set_status(record.run_id, run_status)
return record.run_id
run_id = asyncio.run(_seed())
app = make_authed_test_app()
app.include_router(thread_runs.router)
app.state.run_manager = mgr
app.state.stream_bridge = MemoryStreamBridge()
return TestClient(app, raise_server_exceptions=False), mgr, run_id
def _get_run_status(mgr: RunManager, run_id: str) -> RunStatus:
async def _read_status() -> RunStatus:
record = await mgr.get(run_id)
assert record is not None
return record.status
return asyncio.run(_read_status())
@pytest.mark.parametrize("action", ("interrupt", "rollback"))
def test_get_with_cancel_action_is_rejected(action: str):
"""GET + action=interrupt|rollback must answer 405, not cancel."""
client, mgr, run_id = _make_seeded_run_client()
response = client.get(f"/api/threads/{THREAD_ID}/runs/{run_id}/stream?action={action}")
assert response.status_code == 405
assert response.headers["allow"] == "POST"
assert "POST" in response.json()["detail"]
assert _get_run_status(mgr, run_id) == RunStatus.running
def test_get_with_invalid_action_has_one_validation_error():
"""The dependency must not duplicate the endpoint's query validation."""
client, mgr, run_id = _make_seeded_run_client()
response = client.get(f"/api/threads/{THREAD_ID}/runs/{run_id}/stream?action=invalid")
assert response.status_code == 422
assert len(response.json()["detail"]) == 1
assert _get_run_status(mgr, run_id) == RunStatus.running
def test_unsupported_method_preserves_post_allow_header():
"""Splitting the handlers must not change Starlette's route precedence."""
client, _, run_id = _make_seeded_run_client()
response = client.put(f"/api/threads/{THREAD_ID}/runs/{run_id}/stream")
assert response.status_code == 405
assert response.headers["allow"] == "POST"
def test_get_with_action_is_rejected_before_owner_lookup():
"""The method gate must not reveal whether a thread metadata row exists."""
app = make_authed_test_app(owner_check_passes=False)
app.include_router(thread_runs.router)
client = TestClient(app, raise_server_exceptions=False)
response = client.get(f"/api/threads/{THREAD_ID}/runs/missing-run/stream?action=interrupt")
assert response.status_code == 405
assert response.headers["allow"] == "POST"
app.state.thread_store.check_access.assert_not_awaited()
def test_get_without_action_still_joins():
"""The method guard must not break the plain read-only GET join. The
seeded run is terminal so the SSE stream emits `end` and completes."""
client, _, run_id = _make_seeded_run_client(run_status=RunStatus.success)
with client.stream("GET", f"/api/threads/{THREAD_ID}/runs/{run_id}/stream") as response:
assert response.status_code == 200
events = [line for line in response.iter_lines() if line.startswith("event:")]
assert events[-1].strip() == "event: end"
@pytest.mark.parametrize("action", ("interrupt", "rollback"))
def test_post_with_cancel_action_still_cancels(action: str):
"""The documented POST cancel-then-stream flow is unchanged."""
client, mgr, run_id = _make_seeded_run_client()
with client.stream("POST", f"/api/threads/{THREAD_ID}/runs/{run_id}/stream?action={action}") as response:
assert response.status_code == 200
assert _get_run_status(mgr, run_id) == RunStatus.interrupted