fastapi - ✅(Solved) Fix Bug: SSE protocol injection via unvalidated event and id fields in format_sse_event() [1 pull requests, 2 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
fastapi/fastapi#15188Fetched 2026-04-08 01:12:09
View on GitHub
Comments
2
Participants
2
Timeline
6
Reactions
0
Timeline (top)
commented ×2closed ×1cross-referenced ×1mentioned ×1

Fix Action

Fix / Workaround

The browser dispatches a chat event with data = '"message 1 from server"'. ✅

Per the SSE spec, the last event: field in a block wins. The browser dispatches an admin_command event instead of chat. If the frontend has an addEventListener('admin_command', ...) handler, it fires on attacker-controlled input.

An attacker who can influence the model's output via prompt injection can embed \n into chunk.type, fabricating arbitrary SSE events in the stream the frontend receives. This turns a prompt injection into a client-side command injection if the frontend dispatches on event types.

PR fix notes

PR #15187: fix: reject newline characters in SSE event and id fields

Description (problem / solution / changelog)

Summary

format_sse_event() in fastapi/sse.py interpolated the event and id fields into SSE wire-format bytes without stripping or rejecting newline characters. A \n or \r\n in either field injects an additional SSE field-line into the stream, enabling event-type spoofing and fabricated data: payloads in browser EventSource clients.

The data and comment fields in the same function are correctly protected via splitlines(). The omission for event and id is an inconsistency — not a design decision.

Vulnerability class: SSE Protocol Injection (CRLF injection into wire format) CWE: CWE-116 — Improper Encoding for Output in a Different Plaintext Context OWASP: A03:2021 — Injection Prior art: Hono GHSA-p6xx-57qc-3wxr — identical bug class, same fix pattern

Inconsistency before this fix

FieldNewline handlingSafe?
datasplitlines() loop✅ Yes
commentsplitlines() loop✅ Yes
eventNone❌ No
idOnly \0 check❌ No
retryint type (no strings)✅ Yes

What the injection looks like

A request like GET /stream?type=chat%0Adata:%20{"cmd":"exec"} causes format_sse_event to produce:

event: chat
data: {"cmd":"exec"}
data: "real server data"

The browser's EventSource receives a chat event with a fabricated data: field prepended to the real payload. Frontends that process event.data line-by-line (common in streaming AI/MCP UIs) receive a corrupted payload.

Changes

  • fastapi/sse.py

    • Add _check_event_no_newline() validator; apply to ServerSentEvent.event via AfterValidator (mirrors the existing _check_id_no_null pattern)
    • Extend _check_id_no_null() to also reject \n and \r
    • Strip \r/\n from event and id in format_sse_event() as defense-in-depth (low-level function; callable without going through the Pydantic model validators)
  • tests/test_sse.py

    • 7 new regression tests covering LF/CR rejection in ServerSentEvent.event, ServerSentEvent.id, and the format_sse_event() low-level function

Test results

All 25 tests pass (pytest tests/test_sse.py -v), including the 7 new ones.

Severity

Low at framework level / Medium in affected applications. The vulnerability only activates when a developer routes attacker-influenced input into the event= or id= fields — most correct uses pass static strings. The fix is a one-line guard that prevents the framework from silently accepting malformed input.

Changed files

  • fastapi/sse.py (modified, +27/-4)
  • tests/test_sse.py (modified, +67/-0)

Code Example

event: message\n
id: 42\n
data: {"text": "hello"}\n
\n

---

def format_sse_event(
    *,
    data_str: str | None = None,
    event: str | None = None,
    id: str | None = None,
    retry: int | None = None,
    comment: str | None = None,
) -> bytes:
    lines: list[str] = []

    if comment is not None:
        for line in comment.splitlines():      # ✅ SAFE — splits on newlines
            lines.append(f": {line}")

    if event is not None:
        lines.append(f"event: {event}")        # ❌ UNSAFE — raw f-string, no check

    if data_str is not None:
        for line in data_str.splitlines():     # ✅ SAFE — splits on newlines
            lines.append(f"data: {line}")

    if id is not None:
        lines.append(f"id: {id}")              # ❌ UNSAFE — raw f-string, no newline check

    if retry is not None:
        lines.append(f"retry: {retry}")

    lines.append("")
    lines.append("")
    return "\n".join(lines).encode("utf-8")

---

def _check_id_no_null(v: str | None) -> str | None:
    if v is not None and "\0" in v:
        raise ValueError("SSE 'id' must not contain null characters")
    return v

---

id: Annotated[
    str | None,
    AfterValidator(_check_id_no_null),  # only checks \0
    ...
] = None

---

# app.py
import asyncio
from fastapi import FastAPI
from fastapi.responses import EventSourceResponse
from fastapi.sse import ServerSentEvent

app = FastAPI()

@app.get("/stream")
async def stream(type: str = "chat"):
    # VULNERABLE: user-controlled `type` passed directly to event=
    async def generator():
        for i in range(3):
            yield ServerSentEvent(
                event=type,                        # no validation
                data=f"message {i+1} from server"
            )
            await asyncio.sleep(0.3)
    return EventSourceResponse(generator())

---

GET /stream?type=chat HTTP/1.1

---

b'event: chat\ndata: "message 1 from server"\n\n'

---

event: chat
data: "message 1 from server"

---

GET /stream?type=chat%0Apwned HTTP/1.1

---

b'event: chat\npwned\ndata: "message 1 from server"\n\n'

---

event: chat          <- field terminated at \n, value is "chat"
pwned                <- new line with no colon, spec says ignore unknown fields
data: "message 1 from server"

---

GET /stream?type=chat%0Adata:%20{"cmd":"exec","payload":"rm+-rf+/tmp"} HTTP/1.1

---

event: chat
data: {"cmd":"exec","payload":"rm -rf /tmp"}
data: "message 1 from server"

---

GET /stream?type=chat%0Aevent:%20admin_command HTTP/1.1

---

event: chat
event: admin_command
data: "message 1 from server"

---

Normal/raw?type=chat
  repr:    b'event: chat\ndata: "message 1 from server"\n\n'
  decoded: event: chat
           data: "message 1 from server"

Injected/raw?type=chat%0Apwned
  repr:    b'event: chat\npwned\ndata: "message 1 from server"\n\n'
  decoded: event: chat
           pwned
           data: "message 1 from server"

---

async def stream_llm(prompt: str):
    async for chunk in llm.stream(prompt):
        yield ServerSentEvent(
            event=chunk.type,     # model output used as event type
            data=chunk.content
        )

---

yield ServerSentEvent(
    event=user.notification_channel,   # DB value, set by user
    data=notification_payload
)

---

yield ServerSentEvent(
    event=message.category,   # user-supplied during message creation
    data=message.content
)

---

yield ServerSentEvent(event="message", data=content)   # static, not vulnerable
yield ServerSentEvent(event="done", data=None)         # static, not vulnerable

---

# fastapi/sse.py:199-200  (current)
if event is not None:
    lines.append(f"event: {event}")

# fixed
if event is not None:
    event_clean = event.replace("\r", "").replace("\n", "")
    lines.append(f"event: {event_clean}")

---

# fastapi/sse.py:206-207  (current)
if id is not None:
    lines.append(f"id: {id}")

# fixed
if id is not None:
    id_clean = id.replace("\r", "").replace("\n", "")
    lines.append(f"id: {id_clean}")

---

# current
def _check_id_no_null(v: str | None) -> str | None:
    if v is not None and "\0" in v:
        raise ValueError("SSE 'id' must not contain null characters")
    return v

# fixed
def _check_id_no_null(v: str | None) -> str | None:
    if v is not None:
        if "\0" in v:
            raise ValueError("SSE 'id' must not contain null characters")
        if "\n" in v or "\r" in v:
            raise ValueError("SSE 'id' must not contain newline characters")
    return v

---

class ServerSentEvent(BaseModel):
    event: Annotated[
        str | None,
        AfterValidator(_check_event_no_newline),
    ] = None

def _check_event_no_newline(v: str | None) -> str | None:
    if v is not None and ("\n" in v or "\r" in v):
        raise ValueError("SSE 'event' must not contain newline or carriage return characters")
    return v

---

if event is not None:
    first_line = event.splitlines()
    lines.append(f"event: {first_line[0] if first_line else ''}")
RAW_BUFFERClick to expand / collapse

Severity: Low (framework-level), Medium/High in affected applications CWE: CWE-116: Improper Encoding for Output in a Different Plaintext Context OWASP: A03:2021: Injection Affected version: FastAPI 0.135.1 (master, commit 2742546af) Affected file: fastapi/sse.py (FastAPI-owned code, not Starlette) Fix PR: #15187 Discovered: 2026-03-20


What's happening

format_sse_event() builds SSE wire-format bytes by directly interpolating the event and id fields into output lines without stripping or rejecting newline characters. If either field contains a \n or \r\n, it injects an extra SSE field-line into the stream. The browser's EventSource parser has no way to tell injected lines from real ones, so it treats them as valid protocol fields.

The data and comment fields in the same function are already handled safely with splitlines(). The omission for event and id looks like an oversight rather than a deliberate choice.


Background: the SSE wire format

SSE is a plain-text streaming protocol from the HTML Living Standard §9.2. Each event is a sequence of field: value lines separated by blank lines:

event: message\n
id: 42\n
data: {"text": "hello"}\n
\n

The newline is the entire field separator. There is no quoting, no escaping, no length prefix. The parser reads byte-by-byte and splits on \n and \r\n. A newline inside a value is structurally identical to a field separator, so it creates a new field-line.

Valid field names are event, id, data, and retry. Lines starting with : are comments. The spec says unknown field names are ignored, but data: and event: are valid and directly affect browser behavior, so injecting them is meaningful.


Vulnerable code

fastapi/sse.py:146-214format_sse_event()

def format_sse_event(
    *,
    data_str: str | None = None,
    event: str | None = None,
    id: str | None = None,
    retry: int | None = None,
    comment: str | None = None,
) -> bytes:
    lines: list[str] = []

    if comment is not None:
        for line in comment.splitlines():      # ✅ SAFE — splits on newlines
            lines.append(f": {line}")

    if event is not None:
        lines.append(f"event: {event}")        # ❌ UNSAFE — raw f-string, no check

    if data_str is not None:
        for line in data_str.splitlines():     # ✅ SAFE — splits on newlines
            lines.append(f"data: {line}")

    if id is not None:
        lines.append(f"id: {id}")              # ❌ UNSAFE — raw f-string, no newline check

    if retry is not None:
        lines.append(f"retry: {retry}")

    lines.append("")
    lines.append("")
    return "\n".join(lines).encode("utf-8")

fastapi/sse.py:36-39_check_id_no_null()

The only validation on id is a null-byte check. It does not reject \n or \r:

def _check_id_no_null(v: str | None) -> str | None:
    if v is not None and "\0" in v:
        raise ValueError("SSE 'id' must not contain null characters")
    return v

fastapi/sse.py:98-109ServerSentEvent.id field

id: Annotated[
    str | None,
    AfterValidator(_check_id_no_null),  # only checks \0
    ...
] = None

event has no validator at all (fastapi/sse.py:87-97).


Where the inconsistency shows up

FieldNewline handlingSafe?
datasplitlines() loop✅ Yes
commentsplitlines() loop✅ Yes
eventNone❌ No
idOnly \0 check❌ No
retryint type (no strings)✅ Yes

data and comment got explicit protection. event and id did not. Given the pattern, this looks like they were missed, not intentionally left open.


Proof of concept

Setup

# app.py
import asyncio
from fastapi import FastAPI
from fastapi.responses import EventSourceResponse
from fastapi.sse import ServerSentEvent

app = FastAPI()

@app.get("/stream")
async def stream(type: str = "chat"):
    # VULNERABLE: user-controlled `type` passed directly to event=
    async def generator():
        for i in range(3):
            yield ServerSentEvent(
                event=type,                        # no validation
                data=f"message {i+1} from server"
            )
            await asyncio.sleep(0.3)
    return EventSourceResponse(generator())

Step 1: normal request

GET /stream?type=chat HTTP/1.1

Wire bytes from format_sse_event:

b'event: chat\ndata: "message 1 from server"\n\n'

Decoded:

event: chat
data: "message 1 from server"

The browser dispatches a chat event with data = '"message 1 from server"'. ✅

Step 2: injected request

GET /stream?type=chat%0Apwned HTTP/1.1

%0A is a URL-encoded newline. FastAPI URL-decodes it before the endpoint receives it, so type = "chat\npwned".

Wire bytes:

b'event: chat\npwned\ndata: "message 1 from server"\n\n'

Decoded:

event: chat          <- field terminated at \n, value is "chat"
pwned                <- new line with no colon, spec says ignore unknown fields
data: "message 1 from server"

Step 3: fabricated data field

GET /stream?type=chat%0Adata:%20{"cmd":"exec","payload":"rm+-rf+/tmp"} HTTP/1.1

Wire bytes:

event: chat
data: {"cmd":"exec","payload":"rm -rf /tmp"}
data: "message 1 from server"

The browser's EventSource receives a chat event. The data property is the concatenation of all data: lines joined by \n, so JSON.parse(event.data) fails on the combined string. The exploitable path is frontends that process event.data line-by-line or pass the first line to a parser, which is a common pattern in streaming AI UIs where each data: line is expected to be a separate JSON object.

Step 4: event type spoofing

GET /stream?type=chat%0Aevent:%20admin_command HTTP/1.1

Wire bytes:

event: chat
event: admin_command
data: "message 1 from server"

Per the SSE spec, the last event: field in a block wins. The browser dispatches an admin_command event instead of chat. If the frontend has an addEventListener('admin_command', ...) handler, it fires on attacker-controlled input.

Verified output (tested against FastAPI 0.135.1)

Normal  — /raw?type=chat
  repr:    b'event: chat\ndata: "message 1 from server"\n\n'
  decoded: event: chat
           data: "message 1 from server"

Injected — /raw?type=chat%0Apwned
  repr:    b'event: chat\npwned\ndata: "message 1 from server"\n\n'
  decoded: event: chat
           pwned
           data: "message 1 from server"

Attack scenarios

Scenario A: LLM / MCP streaming apps (highest risk)

FastAPI is widely used for LLM inference servers and MCP (Model Context Protocol) transports. A typical pattern:

async def stream_llm(prompt: str):
    async for chunk in llm.stream(prompt):
        yield ServerSentEvent(
            event=chunk.type,     # model output used as event type
            data=chunk.content
        )

An attacker who can influence the model's output via prompt injection can embed \n into chunk.type, fabricating arbitrary SSE events in the stream the frontend receives. This turns a prompt injection into a client-side command injection if the frontend dispatches on event types.

Scenario B: user-configurable notification channels

An app lets users name their notification channel. The name is stored in the DB and used as the SSE event type:

yield ServerSentEvent(
    event=user.notification_channel,   # DB value, set by user
    data=notification_payload
)

A user who sets their channel name to update\ndata: <script>alert(1)</script> poisons every subscriber's SSE stream with an injected data: line.

Scenario C: chat room event routing

A chat app routes messages to event types based on message category:

yield ServerSentEvent(
    event=message.category,   # user-supplied during message creation
    data=message.content
)

Stored injection: an attacker creates a message with category = "dm\nretry: 999999999". Every client receiving that event gets a retry: field with a multi-billion millisecond reconnect delay. This only affects reconnection after a disconnect, not the active stream, so the practical result is a severely delayed reconnection rather than a persistent DoS.


Impact

Injection payloadEffect
event_type\ndata: {"cmd": "..."}Fabricated data field in the event block
event_type\nevent: admin_commandEvent type spoofing, triggers different frontend handlers
event_type\nretry: 2147483648Delayed reconnection after disconnect, does not affect the active stream
event_type\nid: attacker_idOverrides the Last-Event-ID sent on reconnection
event_type\n\n\nevent: fakeDouble blank line terminates the current event early and starts a new one

The impact is client-side only. The server itself is not directly affected. Impact on clients ranges from low (extra ignored field-lines with unknown names) to medium (spoofed event types triggering wrong frontend handlers) to high in apps where fabricated data: fields get processed by JS logic as authoritative server commands.


Why this is low severity at the framework level

The framework cannot tell the difference between developer-controlled and user-controlled values passed to ServerSentEvent(event=...). Most correct uses pass static strings:

yield ServerSentEvent(event="message", data=content)   # static, not vulnerable
yield ServerSentEvent(event="done", data=None)         # static, not vulnerable

The bug only surfaces when a developer routes attacker-influenced input into the event= or id= fields. The fix is a one-line guard that stops the framework from silently accepting malformed input.

The same bug class in Hono (GHSA-p6xx-57qc-3wxr) was rated medium severity.


Prior art

The same issue was reported against Hono in 2024:

GHSA-p6xx-57qc-3wxr — "SSE Control Field Injection via CR/LF in writeSSE()" The event, id, and retry fields were not validated for \r or \n. Fixed by stripping control characters before output.

FastAPI's sse.py has the same gap for event and id. The fix Hono applied is the same fix needed here.


Remediation

Option 1: strip newlines (minimal, non-breaking)

# fastapi/sse.py:199-200  (current)
if event is not None:
    lines.append(f"event: {event}")

# fixed
if event is not None:
    event_clean = event.replace("\r", "").replace("\n", "")
    lines.append(f"event: {event_clean}")

Same for id:

# fastapi/sse.py:206-207  (current)
if id is not None:
    lines.append(f"id: {id}")

# fixed
if id is not None:
    id_clean = id.replace("\r", "").replace("\n", "")
    lines.append(f"id: {id_clean}")

Also extend _check_id_no_null to reject \n and \r:

# current
def _check_id_no_null(v: str | None) -> str | None:
    if v is not None and "\0" in v:
        raise ValueError("SSE 'id' must not contain null characters")
    return v

# fixed
def _check_id_no_null(v: str | None) -> str | None:
    if v is not None:
        if "\0" in v:
            raise ValueError("SSE 'id' must not contain null characters")
        if "\n" in v or "\r" in v:
            raise ValueError("SSE 'id' must not contain newline characters")
    return v

Option 2: raise on newlines (strict, breaking for malformed input)

class ServerSentEvent(BaseModel):
    event: Annotated[
        str | None,
        AfterValidator(_check_event_no_newline),
    ] = None

def _check_event_no_newline(v: str | None) -> str | None:
    if v is not None and ("\n" in v or "\r" in v):
        raise ValueError("SSE 'event' must not contain newline or carriage return characters")
    return v

Option 3: use splitlines() consistently (matches existing data/comment pattern)

if event is not None:
    first_line = event.splitlines()
    lines.append(f"event: {first_line[0] if first_line else ''}")

Note: splitlines()[0] raises IndexError on an empty string. Options 1 or 2 avoid this edge case.


References

extent analysis

Fix Plan

To fix the Server-Sent Events (SSE) control field injection vulnerability in FastAPI, follow these steps:

  • Option 1: Strip newlines (minimal, non-breaking)
    • Modify the format_sse_event function in fastapi/sse.py to strip newlines from the event and id fields:

if event is not None: event_clean = event.replace("\r", "").replace("\n", "") lines.append(f"event: {event_clean}")

if id is not None: id_clean = id.replace("\r", "").replace("\n", "") lines.append(f"id: {id_clean}")

    *   Update the `_check_id_no_null` function to reject newline characters:
        ```python
def _check_id_no_null(v: str | None) -> str | None:
    if v is not None:
        if "\0" in v:
            raise ValueError("SSE 'id' must not contain null characters")
        if "\n" in v or "\r" in v:
            raise ValueError("SSE 'id' must not contain newline characters")
    return v
  • Option 2: Raise on newlines (strict, breaking for malformed input)
    • Add a validator to the ServerSentEvent model to check for newline characters in the event field:

class ServerSentEvent(BaseModel): event: Annotated[ str | None, AfterValidator(_check_event_no_newline), ] = None

def _check_event_no_newline(v: str | None) -> str | None: if v is not None and ("\n" in v or "\r" in v): raise ValueError("SSE 'event' must not contain newline or carriage return characters") return v

*   **Option 3: Use `splitlines()` consistently (matches existing data/comment pattern)**
    *   Modify the `format_sse_event` function to use `splitlines()` for the `event` field:
        ```python
if event is not None:
    first_line = event.splitlines()
    lines.append(f"event: {first_line[0] if first_line else ''}")

Verification

To verify the fix, test the SSE endpoint with different input scenarios, including:

  • Normal requests with valid event types and IDs
  • Requests with injected newline characters in the event type or ID
  • Requests with fabricated data fields or event types

Verify that the server correctly handles these scenarios and does not introduce any security vulnerabilities.

Extra Tips

  • Always validate and sanitize user-input data to prevent security vulnerabilities.
  • Use consistent encoding and decoding mechanisms to avoid issues with newline characters.
  • Consider implementing additional security measures, such as input validation and sanitization,

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING