Security and resilience improvements: We now only record messages that have been sent over radio to db, standard web interface listening port is now localhost and the webserver now checks the X-Sec-Fetch header and blocks if its not same origin.

This commit is contained in:
2026-05-04 22:17:01 +02:00
parent 675a18d940
commit 922de8cc67
5 changed files with 31 additions and 6 deletions
+3
View File
@@ -15,6 +15,9 @@ services:
LORABOT_LLM__BASE_URL: ${LORABOT_LLM_BASE_URL:?set LORABOT_LLM_BASE_URL}
LORABOT_LLM__API_KEY: ${LORABOT_LLM_API_KEY:-not-needed}
LORABOT_LLM__MODEL: ${LORABOT_LLM_MODEL:?set LORABOT_LLM_MODEL}
# The app defaults to loopback; inside the container we need 0.0.0.0 so the
# docker port mapping below can reach it. Restrict exposure at the host port.
LORABOT_WEB__HOST: ${LORABOT_WEB_HOST:-0.0.0.0}
ports:
# Built-in read-only web UI. Override via LORABOT_WEB_PORT.
- "${LORABOT_WEB_PORT:-8080}:8080"
+3 -1
View File
@@ -41,7 +41,9 @@ class MessageCfg(BaseModel):
class WebCfg(BaseModel):
enabled: bool = True
host: str = "0.0.0.0"
# Default to loopback. Conversation logs are unauthenticated; only set this to
# 0.0.0.0 (e.g. inside Docker) when you understand the exposure.
host: str = "127.0.0.1"
port: int = Field(default=8080, gt=0, lt=65536)
+4
View File
@@ -99,6 +99,10 @@ def build_dm_handler(
return
delivered = await send_chunked(mc, contact, reply, cfg.message.max_bytes)
if not delivered:
# Nothing made it onto the radio; don't persist anything
return
db.add_message(db_conn, public_key, "assistant", delivered, hidden_from_llm=is_command)
state.publish("message", {
"public_key": public_key,
+8 -5
View File
@@ -32,18 +32,20 @@ async def resolve_contact(mc: MeshCore, prefix: str, lock: asyncio.Lock):
async def send_chunked(mc: MeshCore, contact, text: str, max_bytes: int, max_chunks: int = 2) -> str:
"""Split ``text`` into byte-budgeted chunks and send them in order.
Stops sending on the first transport error. Returns the actually-delivered text
(concatenated chunks that the caller should record as the assistant turn).
Stops sending on the first transport error. Returns the concatenation of the
chunks that were actually accepted by the device, so the caller records the
truth — not what we hoped to send.
"""
chunks = split_to_bytes(text, max_bytes, max_chunks=max_chunks)
delivered = "".join(chunks)
pk_short = contact["public_key"][:12]
planned_bytes = sum(len(c.encode("utf-8")) for c in chunks)
dropped = len(text.encode("utf-8")) - len(delivered.encode("utf-8"))
dropped = len(text.encode("utf-8")) - planned_bytes
if dropped > 0:
log.info("reply to %s split into %d chunks, dropped %d trailing bytes",
pk_short, len(chunks), dropped)
sent: list[str] = []
for i, chunk in enumerate(chunks, 1):
log.info("reply to %s (%d/%d, %d bytes): %s",
pk_short, i, len(chunks), len(chunk.encode("utf-8")), chunk)
@@ -52,4 +54,5 @@ async def send_chunked(mc: MeshCore, contact, text: str, max_bytes: int, max_chu
log.error("send_msg failed for %s chunk %d/%d: %s",
pk_short, i, len(chunks), result.payload)
break
return delivered
sent.append(chunk)
return "".join(sent)
+13
View File
@@ -173,7 +173,20 @@ async def _api_conversations(req: web.Request) -> web.Response:
return web.json_response(_list_conversations(_state(req).db))
def _require_same_origin(req: web.Request) -> None:
"""Reject obvious cross-site browser requests on state-changing endpoints.
``Sec-Fetch-Site`` is set by all current browsers on every request. ``same-origin``
is the only value we accept. Non-browser clients (curl, scripts) don't send the
header at all — those pass through, since they're not the CSRF threat model.
"""
site = req.headers.get("Sec-Fetch-Site")
if site is not None and site != "same-origin":
raise web.HTTPForbidden(text=f"cross-origin request rejected (Sec-Fetch-Site: {site})")
async def _api_advertise(req: web.Request) -> web.Response:
_require_same_origin(req)
state = _state(req)
if state._advertise is None:
raise web.HTTPServiceUnavailable(text="device not connected")