flowgraphapp 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flowgraph/__init__.py +8 -0
- flowgraph/_static/_headers +21 -0
- flowgraph/_static/assets/JsonCodeEditor-CBMWkoPU.js +12 -0
- flowgraph/_static/assets/index-BE9FlCos.css +2 -0
- flowgraph/_static/assets/index-SiLO3vOy.js +157 -0
- flowgraph/_static/assets/pdf-B_9Q7Dif.js +11 -0
- flowgraph/_static/assets/pdf.worker.min-0p99Cwul.js +1 -0
- flowgraph/_static/assets/pdf.worker.min-yatZIOMy.mjs +21 -0
- flowgraph/_static/favicon.svg +1 -0
- flowgraph/_static/icons.svg +24 -0
- flowgraph/_static/index.html +15 -0
- flowgraph/ai_proxy.py +146 -0
- flowgraph/cli.py +201 -0
- flowgraph/config.py +125 -0
- flowgraph/keys.py +133 -0
- flowgraph/providers.py +89 -0
- flowgraph/runtime_config.py +27 -0
- flowgraph/security.py +291 -0
- flowgraph/server.py +96 -0
- flowgraph/update_check.py +118 -0
- flowgraphapp-0.1.1.dist-info/METADATA +108 -0
- flowgraphapp-0.1.1.dist-info/RECORD +25 -0
- flowgraphapp-0.1.1.dist-info/WHEEL +4 -0
- flowgraphapp-0.1.1.dist-info/entry_points.txt +2 -0
- flowgraphapp-0.1.1.dist-info/licenses/LICENSE +18 -0
flowgraph/keys.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Provider API keys — stored in the OS keychain, never in the wheel or the browser.
|
|
2
|
+
|
|
3
|
+
Resolution precedence (mirrors Simon Willison's `llm` CLI, the de-facto standard):
|
|
4
|
+
explicit value > OS keychain (`keyring`) > environment variable > 0600 config file.
|
|
5
|
+
The 0600 file is a fallback used ONLY when no OS keychain backend is available
|
|
6
|
+
(e.g. headless Linux where keyring degrades to the Null backend). The key source is
|
|
7
|
+
always reported so the UI/trace can be honest about where a secret came from.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional, Tuple
|
|
16
|
+
|
|
17
|
+
from .config import config_dir
|
|
18
|
+
|
|
19
|
+
SERVICE = "flowgraph"
|
|
20
|
+
|
|
21
|
+
# provider -> accepted environment-variable names (first hit wins)
|
|
22
|
+
ENV_VARS = {
|
|
23
|
+
"anthropic": ("ANTHROPIC_API_KEY",),
|
|
24
|
+
"openrouter": ("OPENROUTER_API_KEY",),
|
|
25
|
+
"openai": ("OPENAI_API_KEY",),
|
|
26
|
+
"google": ("GEMINI_API_KEY", "GOOGLE_API_KEY"),
|
|
27
|
+
"deepseek": ("DEEPSEEK_API_KEY",),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _keys_file() -> Path:
|
|
32
|
+
return config_dir() / "keys.json"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _keyring():
|
|
36
|
+
"""Return the keyring module iff a real OS backend is available, else None."""
|
|
37
|
+
try:
|
|
38
|
+
import keyring
|
|
39
|
+
kr = keyring.get_keyring()
|
|
40
|
+
backend = type(kr).__module__.lower()
|
|
41
|
+
if "fail" in backend or "null" in backend:
|
|
42
|
+
return None
|
|
43
|
+
return keyring
|
|
44
|
+
except Exception:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def keyring_available() -> bool:
|
|
49
|
+
return _keyring() is not None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_key(provider: str, explicit: Optional[str] = None) -> Tuple[Optional[str], str]:
|
|
53
|
+
"""Return (key, source). source in {explicit, keychain, env, file, none}."""
|
|
54
|
+
if explicit:
|
|
55
|
+
return explicit, "explicit"
|
|
56
|
+
|
|
57
|
+
kr = _keyring()
|
|
58
|
+
if kr is not None:
|
|
59
|
+
try:
|
|
60
|
+
v = kr.get_password(SERVICE, provider)
|
|
61
|
+
if v:
|
|
62
|
+
return v, "keychain"
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
for env in ENV_VARS.get(provider, ()):
|
|
67
|
+
v = os.environ.get(env)
|
|
68
|
+
if v:
|
|
69
|
+
return v, "env"
|
|
70
|
+
|
|
71
|
+
v = _read_file().get(provider)
|
|
72
|
+
if v:
|
|
73
|
+
return v, "file"
|
|
74
|
+
|
|
75
|
+
return None, "none"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def set_key(provider: str, value: str) -> str:
|
|
79
|
+
"""Persist a key. Returns the storage backend used ('keychain' or 'file')."""
|
|
80
|
+
kr = _keyring()
|
|
81
|
+
if kr is not None:
|
|
82
|
+
try:
|
|
83
|
+
kr.set_password(SERVICE, provider, value)
|
|
84
|
+
return "keychain"
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
data = _read_file()
|
|
88
|
+
data[provider] = value
|
|
89
|
+
_write_file(data)
|
|
90
|
+
return "file"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def delete_key(provider: str) -> None:
|
|
94
|
+
kr = _keyring()
|
|
95
|
+
if kr is not None:
|
|
96
|
+
try:
|
|
97
|
+
kr.delete_password(SERVICE, provider)
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
data = _read_file()
|
|
101
|
+
if provider in data:
|
|
102
|
+
del data[provider]
|
|
103
|
+
_write_file(data)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _read_file() -> dict:
|
|
107
|
+
p = _keys_file()
|
|
108
|
+
if not p.is_file():
|
|
109
|
+
return {}
|
|
110
|
+
try:
|
|
111
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
112
|
+
except Exception:
|
|
113
|
+
return {}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _write_file(data: dict) -> None:
|
|
117
|
+
"""Atomic 0600 write — the fallback store must never be world-readable."""
|
|
118
|
+
p = _keys_file()
|
|
119
|
+
tmp = p.with_name(p.name + ".tmp")
|
|
120
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
121
|
+
try:
|
|
122
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
123
|
+
json.dump(data, f)
|
|
124
|
+
finally:
|
|
125
|
+
try:
|
|
126
|
+
os.chmod(tmp, 0o600)
|
|
127
|
+
except OSError:
|
|
128
|
+
pass
|
|
129
|
+
os.replace(tmp, p)
|
|
130
|
+
try:
|
|
131
|
+
os.chmod(p, 0o600)
|
|
132
|
+
except OSError:
|
|
133
|
+
pass
|
flowgraph/providers.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""LLM provider resolution behind a typed boundary (charter §1 — swappable mechanism).
|
|
2
|
+
|
|
3
|
+
Two pluggable kinds, modelled identically: a local OpenAI-compatible runtime
|
|
4
|
+
(Ollama / LM Studio / llama.cpp — zero key, air-gapped) and BYOK cloud providers
|
|
5
|
+
(key held server-side, never sent to the browser). Default is local-model-first:
|
|
6
|
+
use a reachable local runtime, else fall back to the first BYOK provider with a key.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Optional, Sequence
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from . import keys as keymod
|
|
17
|
+
from .config import LOCAL_RUNTIME_PROBES
|
|
18
|
+
|
|
19
|
+
BYOK_BASES = {
|
|
20
|
+
"anthropic": "https://api.anthropic.com",
|
|
21
|
+
"openrouter": "https://openrouter.ai/api",
|
|
22
|
+
"openai": "https://api.openai.com",
|
|
23
|
+
"deepseek": "https://api.deepseek.com",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
DEFAULT_MODELS = {
|
|
27
|
+
"anthropic": "claude-haiku-4-5-20251001",
|
|
28
|
+
"openrouter": "anthropic/claude-haiku-4.5",
|
|
29
|
+
"openai": "gpt-4o-mini",
|
|
30
|
+
"deepseek": "deepseek-chat",
|
|
31
|
+
"local": "", # let the runtime use its loaded model when unspecified
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ProviderChoice:
|
|
37
|
+
kind: str # 'local' | 'anthropic' | 'openrouter' | 'openai' | 'deepseek'
|
|
38
|
+
label: str # human-readable, for honest surfacing (e.g. "Ollama (local, no key)")
|
|
39
|
+
base_url: str
|
|
40
|
+
api_key: Optional[str]
|
|
41
|
+
key_source: str # 'none' | 'keychain' | 'env' | 'file' | 'explicit'
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def is_anthropic(self) -> bool:
|
|
45
|
+
return self.kind == "anthropic"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def detect_local_runtime(
|
|
49
|
+
client: httpx.AsyncClient, timeout: float = 0.8
|
|
50
|
+
) -> Optional[ProviderChoice]:
|
|
51
|
+
"""Probe well-known OpenAI-compatible local endpoints; return the first reachable."""
|
|
52
|
+
for name, base in LOCAL_RUNTIME_PROBES:
|
|
53
|
+
try:
|
|
54
|
+
r = await client.get(base + "/v1/models", timeout=timeout)
|
|
55
|
+
if r.status_code < 500:
|
|
56
|
+
return ProviderChoice(
|
|
57
|
+
kind="local",
|
|
58
|
+
label=f"{name} (local, no key)",
|
|
59
|
+
base_url=base,
|
|
60
|
+
api_key=None,
|
|
61
|
+
key_source="none",
|
|
62
|
+
)
|
|
63
|
+
except Exception:
|
|
64
|
+
continue
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def resolve_provider(
|
|
69
|
+
client: httpx.AsyncClient,
|
|
70
|
+
*,
|
|
71
|
+
prefer_local: bool = True,
|
|
72
|
+
byok_order: Sequence[str] = ("anthropic", "openrouter", "openai", "deepseek"),
|
|
73
|
+
) -> Optional[ProviderChoice]:
|
|
74
|
+
if prefer_local:
|
|
75
|
+
local = await detect_local_runtime(client)
|
|
76
|
+
if local is not None:
|
|
77
|
+
return local
|
|
78
|
+
|
|
79
|
+
for kind in byok_order:
|
|
80
|
+
key, source = keymod.get_key(kind)
|
|
81
|
+
if key:
|
|
82
|
+
return ProviderChoice(
|
|
83
|
+
kind=kind,
|
|
84
|
+
label=f"{kind} (key from {source})",
|
|
85
|
+
base_url=BYOK_BASES[kind],
|
|
86
|
+
api_key=key,
|
|
87
|
+
key_source=source,
|
|
88
|
+
)
|
|
89
|
+
return None
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Non-secret runtime config served at GET /config.
|
|
2
|
+
|
|
3
|
+
This is the seam the SPA reads (build-once / runtime-config pattern) to learn that it
|
|
4
|
+
is running under the local server and should route AI through the same-origin proxy
|
|
5
|
+
(so the key stays server-side). It NEVER contains a provider key — only the active
|
|
6
|
+
provider's kind/label and key SOURCE, for honest surfacing. The SPA consumes this at
|
|
7
|
+
boot (L1, docs/18 §7.1): `store.init()` → `applyRuntimeConfig` → `resolveAiTransport`
|
|
8
|
+
selects the same-origin proxy; in proxy mode the CSP connect-src is tightened to 'self'.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .config import ServerConfig
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def runtime_config(config: ServerConfig) -> dict:
|
|
18
|
+
return {
|
|
19
|
+
"mode": "local",
|
|
20
|
+
"version": __version__,
|
|
21
|
+
# Same-origin: the SPA can use relative /api/... paths, so no base URL needed.
|
|
22
|
+
"apiBase": "",
|
|
23
|
+
"proxyEnabled": True,
|
|
24
|
+
"transport": "proxy", # hint: prefer the server-side key proxy over browser BYOK
|
|
25
|
+
"auth": "cookie" if config.auth_enabled else "none",
|
|
26
|
+
"localModelFirst": True,
|
|
27
|
+
}
|
flowgraph/security.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""The hardening layer. Assumes ``localhost`` is hostile-reachable.
|
|
2
|
+
|
|
3
|
+
Defenses (all ON by default, fail-closed, enforced as ONE ASGI middleware so they
|
|
4
|
+
cover every route — static assets, API, health, and any future SSE/WebSocket):
|
|
5
|
+
|
|
6
|
+
1. Host-header allow-list -> the load-bearing DNS-rebinding defense. A rebound
|
|
7
|
+
request arrives with ``Host: attacker.com`` and is rejected. Exact host:port
|
|
8
|
+
match only; substrings/suffixes/missing-Host/0.0.0.0/trailing-dot are rejected.
|
|
9
|
+
2. Origin validation -> on every WebSocket handshake and every state-changing
|
|
10
|
+
(non-GET) HTTP request, and on any HTTP request that carries a foreign Origin.
|
|
11
|
+
Defeats Cross-Site WebSocket Hijacking (WS is not covered by CORS).
|
|
12
|
+
3. Session token -> >=256-bit CSPRNG, required on every route except the
|
|
13
|
+
index handshake and /healthz. Constant-time compare. Defends against other local
|
|
14
|
+
users/processes (who can forge Host/Origin but cannot obtain the token).
|
|
15
|
+
4. Security headers -> strict CSP, nosniff, frame-ancestors 'none', etc. on
|
|
16
|
+
ALL responses (treat rendered/streamed content as untrusted).
|
|
17
|
+
|
|
18
|
+
References: Jupyter (allow_remote_access=False), MCP-SDK CVE-2025-66416 (the trap of
|
|
19
|
+
shipping this OFF by default), CWE-1385 (WS origin), the 0.0.0.0-day class.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import hmac
|
|
25
|
+
import secrets
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Optional, Tuple
|
|
30
|
+
|
|
31
|
+
from .config import PROVIDER_ORIGINS, ServerConfig
|
|
32
|
+
|
|
33
|
+
LOOPBACK_HOSTNAMES = frozenset({"127.0.0.1", "localhost", "::1"})
|
|
34
|
+
|
|
35
|
+
# Paths reachable WITHOUT a token. The index handshake and health only; everything
|
|
36
|
+
# else (assets, /config, /api/*) requires auth.
|
|
37
|
+
_AUTH_EXEMPT_PATHS = frozenset({"/", "/index.html", "/healthz"})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# --------------------------------------------------------------------------- tokens
|
|
41
|
+
|
|
42
|
+
def new_session_token() -> str:
|
|
43
|
+
"""256-bit URL-safe token."""
|
|
44
|
+
return secrets.token_urlsafe(32)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def tokens_match(a: str, b: str) -> bool:
|
|
48
|
+
"""Constant-time comparison (never use ==)."""
|
|
49
|
+
if not a or not b:
|
|
50
|
+
return False
|
|
51
|
+
return hmac.compare_digest(a.encode("utf-8"), b.encode("utf-8"))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class _Ticket:
|
|
56
|
+
value: str
|
|
57
|
+
expires_at: float
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TicketStore:
|
|
61
|
+
"""Single-use, short-TTL tickets that bootstrap a session cookie from the launch URL.
|
|
62
|
+
|
|
63
|
+
The long-lived session token is NEVER placed in a URL (it would leak to logs /
|
|
64
|
+
history). Instead the launch URL carries a one-time ticket; the index route
|
|
65
|
+
exchanges it for an HttpOnly SameSite=Strict cookie and strips it via redirect.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, ttl_seconds: int = 600) -> None:
|
|
69
|
+
self._ttl = ttl_seconds
|
|
70
|
+
self._tickets: dict[str, _Ticket] = {}
|
|
71
|
+
|
|
72
|
+
def mint(self) -> str:
|
|
73
|
+
value = secrets.token_urlsafe(24)
|
|
74
|
+
self._tickets[value] = _Ticket(value, time.monotonic() + self._ttl)
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
def consume(self, value: Optional[str]) -> bool:
|
|
78
|
+
if not value:
|
|
79
|
+
return False
|
|
80
|
+
t = self._tickets.pop(value, None)
|
|
81
|
+
if t is None:
|
|
82
|
+
return False
|
|
83
|
+
return t.expires_at >= time.monotonic()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ----------------------------------------------------------------------- host/origin
|
|
87
|
+
|
|
88
|
+
def split_host_port(value: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
|
|
89
|
+
"""Parse a Host/authority header into (host, port). Rejects malformed input."""
|
|
90
|
+
if not value:
|
|
91
|
+
return None, None
|
|
92
|
+
value = value.strip()
|
|
93
|
+
if value.startswith("["): # IPv6 literal: [::1]:port or [::1]
|
|
94
|
+
end = value.find("]")
|
|
95
|
+
if end == -1:
|
|
96
|
+
return None, None
|
|
97
|
+
host = value[1:end]
|
|
98
|
+
rest = value[end + 1:]
|
|
99
|
+
if rest == "":
|
|
100
|
+
return host, ""
|
|
101
|
+
if rest.startswith(":"):
|
|
102
|
+
return host, rest[1:]
|
|
103
|
+
return None, None
|
|
104
|
+
if value.count(":") == 0:
|
|
105
|
+
return value, ""
|
|
106
|
+
if value.count(":") == 1:
|
|
107
|
+
host, port = value.rsplit(":", 1)
|
|
108
|
+
return host, port
|
|
109
|
+
# bare IPv6 without brackets, or otherwise malformed -> reject
|
|
110
|
+
return None, None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def host_allowed(host_header: Optional[str], bound_port: int) -> bool:
|
|
114
|
+
host, port = split_host_port(host_header)
|
|
115
|
+
if host is None:
|
|
116
|
+
return False
|
|
117
|
+
if host.lower() not in LOOPBACK_HOSTNAMES:
|
|
118
|
+
return False
|
|
119
|
+
# We always bind an explicit port; the Host must carry the matching port.
|
|
120
|
+
return port == str(bound_port)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def allowed_origins(config: ServerConfig) -> frozenset:
|
|
124
|
+
scheme = config.scheme
|
|
125
|
+
port = config.port
|
|
126
|
+
origins = set()
|
|
127
|
+
for h in ("127.0.0.1", "localhost"):
|
|
128
|
+
origins.add(f"{scheme}://{h}:{port}")
|
|
129
|
+
origins.add(f"{scheme}://[::1]:{port}")
|
|
130
|
+
return frozenset(origins)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def origin_allowed(origin: Optional[str], config: ServerConfig) -> bool:
|
|
134
|
+
if not origin:
|
|
135
|
+
return False
|
|
136
|
+
return origin in allowed_origins(config)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ------------------------------------------------------------------- security headers
|
|
140
|
+
|
|
141
|
+
def safe_static_path(static_dir: Path, rel: str) -> Optional[Path]:
|
|
142
|
+
"""Resolve ``rel`` under ``static_dir``, or None if it would escape (path traversal)."""
|
|
143
|
+
base = static_dir.resolve()
|
|
144
|
+
candidate = (base / rel).resolve()
|
|
145
|
+
try:
|
|
146
|
+
candidate.relative_to(base)
|
|
147
|
+
except ValueError:
|
|
148
|
+
return None
|
|
149
|
+
return candidate
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def content_security_policy(config: ServerConfig) -> str:
|
|
153
|
+
# connect-src: in PROXY mode (the default — see config.proxy_mode / GET /config) the SPA
|
|
154
|
+
# routes ALL AI through the same-origin proxy, so it never needs a provider or local-runtime
|
|
155
|
+
# origin — tighten to 'self' (docs/18 §4.8/§7.4). In BYOK-direct mode (proxy_mode=False) the
|
|
156
|
+
# browser calls providers/local runtimes itself, so those origins must be permitted.
|
|
157
|
+
if config.proxy_mode:
|
|
158
|
+
connect = ["'self'", *config.extra_connect_src]
|
|
159
|
+
else:
|
|
160
|
+
connect = ["'self'", *PROVIDER_ORIGINS]
|
|
161
|
+
for h in ("127.0.0.1", "localhost"):
|
|
162
|
+
connect += [f"http://{h}:11434", f"http://{h}:1234", f"http://{h}:8080"]
|
|
163
|
+
connect += list(config.extra_connect_src)
|
|
164
|
+
return "; ".join([
|
|
165
|
+
"default-src 'self'",
|
|
166
|
+
"script-src 'self'",
|
|
167
|
+
"style-src 'self' 'unsafe-inline'", # React/Tailwind inline styles
|
|
168
|
+
"img-src 'self' data: blob:",
|
|
169
|
+
"font-src 'self' data:",
|
|
170
|
+
"worker-src 'self' blob:", # pdf.worker
|
|
171
|
+
"connect-src " + " ".join(connect),
|
|
172
|
+
"base-uri 'self'",
|
|
173
|
+
"form-action 'self'",
|
|
174
|
+
"object-src 'none'",
|
|
175
|
+
"frame-ancestors 'none'",
|
|
176
|
+
])
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def security_headers(config: ServerConfig) -> list[tuple[bytes, bytes]]:
|
|
180
|
+
headers = {
|
|
181
|
+
"content-security-policy": content_security_policy(config),
|
|
182
|
+
"x-content-type-options": "nosniff",
|
|
183
|
+
"x-frame-options": "DENY",
|
|
184
|
+
"referrer-policy": "same-origin",
|
|
185
|
+
"permissions-policy": "geolocation=(), microphone=(), camera=()",
|
|
186
|
+
"cross-origin-opener-policy": "same-origin",
|
|
187
|
+
"cross-origin-resource-policy": "same-origin",
|
|
188
|
+
}
|
|
189
|
+
if config.scheme == "https":
|
|
190
|
+
headers["strict-transport-security"] = "max-age=63072000"
|
|
191
|
+
return [(k.encode(), v.encode()) for k, v in headers.items()]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# --------------------------------------------------------------------- ASGI middleware
|
|
195
|
+
|
|
196
|
+
def _header(scope_headers, name: bytes) -> Optional[str]:
|
|
197
|
+
for k, v in scope_headers:
|
|
198
|
+
if k == name:
|
|
199
|
+
try:
|
|
200
|
+
return v.decode("latin-1")
|
|
201
|
+
except Exception:
|
|
202
|
+
return None
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _cookie_token(scope_headers) -> Optional[str]:
|
|
207
|
+
raw = _header(scope_headers, b"cookie")
|
|
208
|
+
if not raw:
|
|
209
|
+
return None
|
|
210
|
+
for part in raw.split(";"):
|
|
211
|
+
part = part.strip()
|
|
212
|
+
if part.startswith("fg_session="):
|
|
213
|
+
return part[len("fg_session="):]
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class LocalSecurityMiddleware:
|
|
218
|
+
"""One ASGI middleware enforcing Host/Origin/token + security headers on every route."""
|
|
219
|
+
|
|
220
|
+
def __init__(self, app, config: ServerConfig) -> None:
|
|
221
|
+
self.app = app
|
|
222
|
+
self.config = config
|
|
223
|
+
|
|
224
|
+
async def __call__(self, scope, receive, send):
|
|
225
|
+
if scope["type"] not in ("http", "websocket"):
|
|
226
|
+
return await self.app(scope, receive, send)
|
|
227
|
+
|
|
228
|
+
headers = scope.get("headers", [])
|
|
229
|
+
host = _header(headers, b"host")
|
|
230
|
+
origin = _header(headers, b"origin")
|
|
231
|
+
is_ws = scope["type"] == "websocket"
|
|
232
|
+
method = "GET" if is_ws else scope.get("method", "GET")
|
|
233
|
+
path = scope.get("path", "/")
|
|
234
|
+
|
|
235
|
+
# 1. Host allow-list — the primary DNS-rebinding defense. Every request.
|
|
236
|
+
if not host_allowed(host, self.config.port):
|
|
237
|
+
return await self._deny(scope, send, 403, "Forbidden host")
|
|
238
|
+
|
|
239
|
+
# 2. Origin validation — WS always; HTTP on non-safe methods or any foreign Origin.
|
|
240
|
+
if is_ws:
|
|
241
|
+
if not origin_allowed(origin, self.config):
|
|
242
|
+
return await self._deny(scope, send, 403, "Forbidden origin")
|
|
243
|
+
else:
|
|
244
|
+
if method not in ("GET", "HEAD", "OPTIONS"):
|
|
245
|
+
if not origin_allowed(origin, self.config):
|
|
246
|
+
return await self._deny(scope, send, 403, "Forbidden origin")
|
|
247
|
+
elif origin is not None and not origin_allowed(origin, self.config):
|
|
248
|
+
return await self._deny(scope, send, 403, "Forbidden origin")
|
|
249
|
+
|
|
250
|
+
# 3. Token auth — unless disabled. The index handshake (ticket->cookie) and
|
|
251
|
+
# /healthz are exempt; the index route itself enforces ticket-or-401.
|
|
252
|
+
if self.config.auth_enabled and not self._is_authed(headers):
|
|
253
|
+
if not (method in ("GET", "HEAD") and path in _AUTH_EXEMPT_PATHS):
|
|
254
|
+
return await self._deny(scope, send, 401, "Authentication required")
|
|
255
|
+
|
|
256
|
+
# Passed the gate — inject security headers on the way out.
|
|
257
|
+
await self.app(scope, receive, self._wrap_send(send))
|
|
258
|
+
|
|
259
|
+
def _is_authed(self, headers) -> bool:
|
|
260
|
+
bearer = _header(headers, b"authorization")
|
|
261
|
+
if bearer and bearer.lower().startswith("bearer "):
|
|
262
|
+
if tokens_match(bearer[7:].strip(), self.config.session_token):
|
|
263
|
+
return True
|
|
264
|
+
cookie = _cookie_token(headers)
|
|
265
|
+
if cookie and tokens_match(cookie, self.config.session_token):
|
|
266
|
+
return True
|
|
267
|
+
return False
|
|
268
|
+
|
|
269
|
+
def _wrap_send(self, send):
|
|
270
|
+
extra = security_headers(self.config)
|
|
271
|
+
|
|
272
|
+
async def wrapped(message):
|
|
273
|
+
if message["type"] == "http.response.start":
|
|
274
|
+
message = dict(message)
|
|
275
|
+
message["headers"] = list(message.get("headers", [])) + extra
|
|
276
|
+
await send(message)
|
|
277
|
+
|
|
278
|
+
return wrapped
|
|
279
|
+
|
|
280
|
+
async def _deny(self, scope, send, status: int, detail: str):
|
|
281
|
+
if scope["type"] == "websocket":
|
|
282
|
+
# Reject the handshake before accept.
|
|
283
|
+
await send({"type": "websocket.close", "code": 1008})
|
|
284
|
+
return
|
|
285
|
+
body = detail.encode("utf-8")
|
|
286
|
+
start_headers = [
|
|
287
|
+
(b"content-type", b"text/plain; charset=utf-8"),
|
|
288
|
+
(b"content-length", str(len(body)).encode()),
|
|
289
|
+
] + security_headers(self.config)
|
|
290
|
+
await send({"type": "http.response.start", "status": status, "headers": start_headers})
|
|
291
|
+
await send({"type": "http.response.body", "body": body})
|
flowgraph/server.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""The FastAPI app: hardened static SPA host + the /api/ai/chat proxy.
|
|
2
|
+
|
|
3
|
+
Routes (all behind LocalSecurityMiddleware — Host/Origin/token + security headers):
|
|
4
|
+
GET /healthz liveness + version (Host-checked, no token)
|
|
5
|
+
GET /config non-secret runtime config for the SPA (auth required)
|
|
6
|
+
POST /api/ai/chat LLM proxy, key stays server-side (auth + Origin required)
|
|
7
|
+
GET /{path} SPA: ticket->cookie handshake at root, then static + SPA fallback
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from fastapi import FastAPI, Request
|
|
15
|
+
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse, RedirectResponse
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
from .ai_proxy import proxy_chat
|
|
19
|
+
from .config import ServerConfig, resolve_static_dir
|
|
20
|
+
from .runtime_config import runtime_config
|
|
21
|
+
from .security import LocalSecurityMiddleware, TicketStore, safe_static_path, tokens_match
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _request_authed(request: Request, config: ServerConfig) -> bool:
|
|
25
|
+
auth = request.headers.get("authorization")
|
|
26
|
+
if auth and auth.lower().startswith("bearer ") and tokens_match(auth[7:].strip(), config.session_token):
|
|
27
|
+
return True
|
|
28
|
+
ck = request.cookies.get("fg_session")
|
|
29
|
+
return bool(ck and tokens_match(ck, config.session_token))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def create_app(config: ServerConfig, tickets: TicketStore) -> FastAPI:
|
|
33
|
+
static_dir = Path(config.static_dir or resolve_static_dir()).resolve()
|
|
34
|
+
config.static_dir = static_dir
|
|
35
|
+
index_html = static_dir / "index.html"
|
|
36
|
+
|
|
37
|
+
app = FastAPI(title="FlowGraph (local)", version=__version__, docs_url=None, redoc_url=None, openapi_url=None)
|
|
38
|
+
app.add_middleware(LocalSecurityMiddleware, config=config)
|
|
39
|
+
|
|
40
|
+
@app.get("/healthz")
|
|
41
|
+
async def healthz():
|
|
42
|
+
return {"status": "ok", "version": __version__}
|
|
43
|
+
|
|
44
|
+
@app.get("/config")
|
|
45
|
+
async def config_route():
|
|
46
|
+
return runtime_config(config)
|
|
47
|
+
|
|
48
|
+
@app.post("/api/ai/chat")
|
|
49
|
+
async def ai_chat(request: Request):
|
|
50
|
+
try:
|
|
51
|
+
body = await request.json()
|
|
52
|
+
except Exception:
|
|
53
|
+
return JSONResponse({"text": None, "error": "invalid-json"}, status_code=400)
|
|
54
|
+
if not isinstance(body, dict) or not isinstance(body.get("messages"), list):
|
|
55
|
+
return JSONResponse({"text": None, "error": "messages-required"}, status_code=400)
|
|
56
|
+
result = await proxy_chat(body, prefer_local=config.local_model_first)
|
|
57
|
+
status = 200 if not result.get("error") else 502
|
|
58
|
+
return JSONResponse(result, status_code=status)
|
|
59
|
+
|
|
60
|
+
@app.get("/{full_path:path}")
|
|
61
|
+
async def spa(full_path: str, request: Request):
|
|
62
|
+
is_root = full_path in ("", "index.html")
|
|
63
|
+
|
|
64
|
+
# Ticket -> cookie handshake (bootstraps the session from the launch URL).
|
|
65
|
+
if is_root and config.auth_enabled and not _request_authed(request, config):
|
|
66
|
+
ticket = request.query_params.get("ticket")
|
|
67
|
+
if ticket and tickets.consume(ticket):
|
|
68
|
+
resp = RedirectResponse(url="/", status_code=302)
|
|
69
|
+
resp.set_cookie(
|
|
70
|
+
"fg_session",
|
|
71
|
+
config.session_token,
|
|
72
|
+
httponly=True,
|
|
73
|
+
samesite="strict",
|
|
74
|
+
secure=(config.scheme == "https"),
|
|
75
|
+
path="/",
|
|
76
|
+
)
|
|
77
|
+
return resp
|
|
78
|
+
return PlainTextResponse(
|
|
79
|
+
"FlowGraph is running. Open it using the URL printed in the terminal "
|
|
80
|
+
"where you launched `flowgraph` (it carries a one-time access ticket).",
|
|
81
|
+
status_code=401,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Serve a static file (path-safe) or fall back to index.html for client-side routes.
|
|
85
|
+
candidate = safe_static_path(static_dir, full_path)
|
|
86
|
+
if candidate is None:
|
|
87
|
+
return PlainTextResponse("Not found", status_code=404) # path traversal blocked
|
|
88
|
+
if candidate.is_file():
|
|
89
|
+
return FileResponse(candidate)
|
|
90
|
+
# Missing asset (has an extension) -> 404; missing route -> SPA fallback.
|
|
91
|
+
last = full_path.rsplit("/", 1)[-1]
|
|
92
|
+
if "." in last:
|
|
93
|
+
return PlainTextResponse("Not found", status_code=404)
|
|
94
|
+
return FileResponse(index_html)
|
|
95
|
+
|
|
96
|
+
return app
|