Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Security
- Sanitize rendered note HTML and add a Host allowlist to the web UI ([#125](https://github.com/CryptoJones/omind/issues/125)): note markdown (authored by agents, synced from mesh peers — untrusted) is now run through DOMPurify before it reaches `innerHTML`, closing a stored-XSS vector where a prompt-injected note could execute JS against the CRUD API when opened. The API also gets a `TrustedHostMiddleware` Host allowlist (localhost by default) as a DNS-rebinding defence, and `omind serve` warns when binding to a non-localhost / all-interfaces address.

### Fixed
- Graph view no longer freezes the tab or leaks loops ([#129](https://github.com/CryptoJones/omind/issues/129)): the synchronous pre-settle is bounded by a work budget (a large vault no longer triggers "page unresponsive" before first paint), the O(n²) all-pairs repulsion is skipped above a node threshold so frames stay cheap at scale, and `destroy()` now removes the leaked `window` mouseup listener. The web app also tracks and tears down the graph's render loop when the pane switches away, instead of discarding the handle and leaking a `requestAnimationFrame` loop per open.
- Web UI no longer 500s under its own poll ([#130](https://github.com/CryptoJones/omind/issues/130)): `OmiStore`'s summary cache is guarded by a lock, so concurrent `list_notes()` calls from FastAPI's threadpool can't hit "dictionary changed size during iteration". The MCP server also caches the `[[wikilink]]` graph build (invalidated by a cheap vault signature), so a burst of graph-tool queries costs one full-vault parse instead of one per tool.
Expand Down
32 changes: 31 additions & 1 deletion src/omind/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -696,14 +696,44 @@ def require_node_id() -> str:
return 0


def _serve_allowed_hosts(host: str) -> list[str]:
"""The Host-header allowlist for a bind host, warning on non-localhost.

A deliberate all-interfaces bind (0.0.0.0 / ::) disables the Host check
(``["*"]``) since the operator opted into remote access; a specific remote
host is added to the localhost allowlist so it works while other hostnames
(a DNS-rebinding attacker's) stay blocked.
"""
from omind.web.app import DEFAULT_ALLOWED_HOSTS

localhost = {"127.0.0.1", "localhost", "::1", "[::1]"}
if host in {"0.0.0.0", "::", ""}:
print(
" WARNING: binding to all interfaces — the web API is UNAUTHENTICATED and "
"Host-header protection is disabled. Prefer --host 127.0.0.1.",
file=sys.stderr,
)
return ["*"]
if host in localhost:
return list(DEFAULT_ALLOWED_HOSTS)
print(
f" WARNING: binding to {host} — the web API is unauthenticated; only expose it "
"on a trusted network.",
file=sys.stderr,
)
return [*DEFAULT_ALLOWED_HOSTS, host]


def _run_serve(args: argparse.Namespace) -> int:
import uvicorn

omi_dir = (args.vault / args.folder).expanduser()
allowed = _serve_allowed_hosts(args.host)
print(f"omind serve -> {omi_dir}")
print(f"open http://{args.host}:{args.port}")
if args.reload:
os.environ["OMIND_OMI_DIR"] = str(omi_dir)
os.environ["OMIND_ALLOWED_HOSTS"] = ",".join(allowed)
uvicorn.run(
"omind.web.app:get_app",
factory=True,
Expand All @@ -714,7 +744,7 @@ def _run_serve(args: argparse.Namespace) -> int:
else:
from omind.web.app import create_app

uvicorn.run(create_app(omi_dir), host=args.host, port=args.port)
uvicorn.run(create_app(omi_dir, allowed_hosts=allowed), host=args.host, port=args.port)
return 0


Expand Down
20 changes: 18 additions & 2 deletions src/omind/web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from starlette.middleware.trustedhost import TrustedHostMiddleware

from omind import graph as graph_mod
from omind.store import (
Expand Down Expand Up @@ -62,9 +63,22 @@ class RawUpdate(BaseModel):
content: str


def create_app(omi_dir: Path | str) -> FastAPI:
#: Host headers accepted by default (a localhost bind). ``testserver`` is
#: Starlette's TestClient host.
DEFAULT_ALLOWED_HOSTS = ["localhost", "127.0.0.1", "[::1]", "testserver"]


def create_app(omi_dir: Path | str, allowed_hosts: list[str] | None = None) -> FastAPI:
store = OmiStore(omi_dir)
app = FastAPI(title="omind", description="OMI memory web UI")
# DNS-rebinding defence: this JSON API is destructive and unauthenticated
# (single-user, bound to localhost). A Host allowlist rejects requests whose
# Host header isn't expected, so a malicious page the user visits can't rebind
# its hostname to 127.0.0.1 and drive the API. ``["*"]`` (chosen by the CLI for
# a deliberate all-interfaces bind) disables the check.
app.add_middleware(
TrustedHostMiddleware, allowed_hosts=allowed_hosts or DEFAULT_ALLOWED_HOSTS
)

@app.get("/api/notes")
def list_notes(include_disabled: bool = False) -> list[dict[str, object]]:
Expand Down Expand Up @@ -155,4 +169,6 @@ def get_app() -> FastAPI:
omi_dir = os.environ.get("OMIND_OMI_DIR")
if not omi_dir:
raise RuntimeError("OMIND_OMI_DIR is not set; launch via `omind serve`.")
return create_app(omi_dir)
hosts_env = os.environ.get("OMIND_ALLOWED_HOSTS")
allowed = [h for h in hosts_env.split(",") if h] if hosts_env else None
return create_app(omi_dir, allowed_hosts=allowed)
9 changes: 8 additions & 1 deletion src/omind/web/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,14 @@ function renderMarkdown(md) {
/(^|[\s(])#([\p{L}\p{N}_][\p{L}\p{N}_/-]*)/gu,
(_, pre, tag) => `${pre}<span class="hash-tag" data-tag="${escapeHtml(tag)}">#${escapeHtml(tag)}</span>`,
);
return marked.parse(src, { gfm: true, breaks: false });
const html = marked.parse(src, { gfm: true, breaks: false });
// Notes are authored by agents and synced from mesh peers — untrusted. Sanitize
// the rendered HTML before it reaches innerHTML so a prompt-injected note (e.g.
// <img src=x onerror=...>, a javascript: link) can't run with same-origin access
// to the CRUD API. DOMPurify keeps our wikilink/tag <a>/<span> (class + data-*
// are allowed) and strips scripts/handlers/dangerous URLs. If the vendor script
// failed to load, fall back to escaping everything rather than trusting it.
return window.DOMPurify ? window.DOMPurify.sanitize(html) : escapeHtml(html);
}

// ---- Sidebar --------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions src/omind/web/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
<link rel="stylesheet" href="vendor/tailwind.css" />
<link rel="stylesheet" href="app.css" />
<script src="vendor/marked.min.js"></script>
<!-- Sanitizes agent-authored / mesh-synced note HTML before it reaches innerHTML. -->
<script src="vendor/purify.min.js"></script>
</head>

<body class="h-full bg-paper font-body text-ink">
Expand Down
3 changes: 3 additions & 0 deletions src/omind/web/static/vendor/purify.min.js

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions tests/test_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,22 @@ def test_list_empty(client: TestClient) -> None:
assert client.get("/api/notes").json() == []


def test_foreign_host_header_is_rejected(omi_dir: Path) -> None:
"""DNS-rebinding defence: a Host not on the allowlist gets 400 (#125)."""
with TestClient(create_app(omi_dir)) as c:
# The default allowlist accepts the TestClient's "testserver" host.
assert c.get("/api/notes").status_code == 200
# A rebound attacker hostname is rejected before reaching the API.
assert c.get("/api/notes", headers={"host": "evil.attacker.example"}).status_code == 400


def test_explicit_allowed_host_is_accepted(omi_dir: Path) -> None:
"""A host the operator bound to (passed via allowed_hosts) is accepted."""
app = create_app(omi_dir, allowed_hosts=["testserver", "omind.lan"])
with TestClient(app) as c:
assert c.get("/api/notes", headers={"host": "omind.lan"}).status_code == 200


def test_full_crud_cycle(client: TestClient, omi_dir: Path) -> None:
payload = {
"title": "Web Note",
Expand Down
Loading