Harden attachment access controls and stabilize the Weixin bridge.

This persists referenced media safely, adds explicit attachment ACL/backfill flows, and fixes the Weixin sidecar so official login state can bridge reliably into oclaw without duplicate replays or dropped replies.

Made-with: Cursor
This commit is contained in:
oliver 2026-04-28 15:38:55 +08:00
parent 6cfaff06f6
commit 31e3962c74
22 changed files with 1885 additions and 55 deletions

View file

@ -4,6 +4,7 @@ import base64
import hashlib
import json
import os
import re
import time
from dataclasses import dataclass
from pathlib import Path
@ -11,6 +12,7 @@ from typing import Any, Optional
from oclaw.platform.config.paths import attachments_dir
_META_SUFFIX: Final[str] = ".meta.json"
_ATTACHMENT_ID_RE: Final[re.Pattern[str]] = re.compile(r"^[a-f0-9]{64}$")
def _utc_ts() -> int:
@ -96,16 +98,23 @@ class AttachmentAssetStore:
self.root = Path(root_dir) if root_dir is not None else attachments_dir()
self.root.mkdir(parents=True, exist_ok=True)
@staticmethod
def _normalize_attachment_id(attachment_id: str) -> str:
aid = str(attachment_id or "").strip().lower()
if not _ATTACHMENT_ID_RE.fullmatch(aid):
raise ValueError("attachment_id_invalid")
return aid
def _data_path(self, attachment_id: str, *, ext: str) -> Path:
# bucket to avoid huge single dir
aid = (attachment_id or "").strip()
aid = self._normalize_attachment_id(attachment_id)
p1, p2 = (aid[:2] or "xx"), (aid[2:4] or "yy")
d = self.root / p1 / p2
d.mkdir(parents=True, exist_ok=True)
return d / f"{aid}{ext}"
def _meta_path(self, attachment_id: str) -> Path:
aid = (attachment_id or "").strip()
aid = self._normalize_attachment_id(attachment_id)
p1, p2 = (aid[:2] or "xx"), (aid[2:4] or "yy")
d = self.root / p1 / p2
d.mkdir(parents=True, exist_ok=True)
@ -158,7 +167,10 @@ class AttachmentAssetStore:
return meta
def get_meta(self, attachment_id: str) -> Optional[AttachmentMeta]:
mp = self._meta_path(attachment_id)
try:
mp = self._meta_path(attachment_id)
except Exception:
return None
if not mp.exists():
return None
try:
@ -170,7 +182,10 @@ class AttachmentAssetStore:
return None
def touch(self, attachment_id: str) -> None:
mp = self._meta_path(attachment_id)
try:
mp = self._meta_path(attachment_id)
except Exception:
return
if not mp.exists():
return
try:
@ -183,12 +198,16 @@ class AttachmentAssetStore:
return
def load_bytes(self, attachment_id: str) -> tuple[bytes, Optional[AttachmentMeta]]:
meta = self.get_meta(attachment_id)
try:
aid = self._normalize_attachment_id(attachment_id)
except Exception:
return b"", None
meta = self.get_meta(aid)
# try find data file by scanning common extensions
exts = (".png", ".jpg", ".jpeg", ".webp", ".gif", "")
data_path = None
for ext in exts:
p = self._data_path(attachment_id, ext=ext)
p = self._data_path(aid, ext=ext)
if p.exists():
data_path = p
break
@ -198,15 +217,19 @@ class AttachmentAssetStore:
blob = data_path.read_bytes()
except Exception:
return b"", meta
self.touch(attachment_id)
self.touch(aid)
return blob, meta
def get_local_path(self, attachment_id: str) -> Path | None:
try:
aid = self._normalize_attachment_id(attachment_id)
except Exception:
return None
exts = (".png", ".jpg", ".jpeg", ".webp", ".gif", "")
for ext in exts:
p = self._data_path(attachment_id, ext=ext)
p = self._data_path(aid, ext=ext)
if p.exists():
self.touch(attachment_id)
self.touch(aid)
return p
return None

View file

@ -607,6 +607,31 @@ class SqliteStore:
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_admin_audit_actor_ts ON admin_audit_log(actor_user_id, timestamp DESC)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS attachment_acl (
attachment_id TEXT NOT NULL,
tenant_id TEXT NOT NULL,
user_id TEXT NOT NULL,
session_id TEXT NOT NULL,
source TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (attachment_id, tenant_id, user_id, session_id, source),
FOREIGN KEY(session_id) REFERENCES chat_session(id) ON DELETE CASCADE,
FOREIGN KEY(tenant_id) REFERENCES tenant(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES app_user(id) ON DELETE CASCADE
);
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_attachment_acl_tenant_attachment ON attachment_acl(tenant_id, attachment_id, created_at DESC)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_attachment_acl_user_attachment ON attachment_acl(tenant_id, user_id, attachment_id, created_at DESC)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_attachment_acl_session_attachment ON attachment_acl(session_id, attachment_id, created_at DESC)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS tool_log (
@ -1306,6 +1331,206 @@ class SqliteStore:
last_message_at=row["last_message_at"],
)
@staticmethod
def _attachments_contain_attachment_id(raw_attachments: Any, *, attachment_id: str) -> bool:
aid = str(attachment_id or "").strip()
if not aid:
return False
obj = raw_attachments
if isinstance(raw_attachments, str):
s = str(raw_attachments or "").strip()
if not s:
return False
try:
obj = json.loads(s)
except Exception:
return False
if isinstance(obj, dict):
items = [obj]
elif isinstance(obj, list):
items = obj
else:
return False
for it in items:
if not isinstance(it, dict):
continue
if str(it.get("attachment_id") or "").strip() == aid:
return True
return False
def attachment_referenced_by_user(self, *, tenant_id: str, user_id: str, attachment_id: str, scan_limit: int = 2000) -> bool:
aid = str(attachment_id or "").strip()
tid = str(tenant_id or "").strip()
uid = str(user_id or "").strip()
if not aid or not tid or not uid:
return False
with self._connect() as conn:
rows = conn.execute(
"""
SELECT m.attachments
FROM chat_message m
INNER JOIN ui_session_owner o ON o.session_id = m.session_id
WHERE o.tenant_id = ? AND o.user_id = ? AND m.attachments IS NOT NULL AND m.attachments <> ''
ORDER BY m.id DESC
LIMIT ?
""",
(tid, uid, int(max(1, scan_limit))),
).fetchall()
for r in rows:
if self._attachments_contain_attachment_id(r["attachments"], attachment_id=aid):
return True
return False
def attachment_referenced_in_tenant(self, *, tenant_id: str, attachment_id: str, scan_limit: int = 4000) -> bool:
aid = str(attachment_id or "").strip()
tid = str(tenant_id or "").strip()
if not aid or not tid:
return False
with self._connect() as conn:
rows = conn.execute(
"""
SELECT m.attachments
FROM chat_message m
INNER JOIN ui_session_owner o ON o.session_id = m.session_id
WHERE o.tenant_id = ? AND m.attachments IS NOT NULL AND m.attachments <> ''
ORDER BY m.id DESC
LIMIT ?
""",
(tid, int(max(1, scan_limit))),
).fetchall()
for r in rows:
if self._attachments_contain_attachment_id(r["attachments"], attachment_id=aid):
return True
return False
def link_attachment_acl(
self,
*,
tenant_id: str,
user_id: str,
session_id: str,
attachment_id: str,
source: str,
) -> None:
tid = str(tenant_id or "").strip()
uid = str(user_id or "").strip()
sid = str(session_id or "").strip()
aid = str(attachment_id or "").strip().lower()
src = str(source or "").strip() or "unknown"
if not tid or not uid or not sid or not aid:
return
ts = utc_now_iso()
with self._connect() as conn:
conn.execute(
"""
INSERT OR IGNORE INTO attachment_acl
(attachment_id, tenant_id, user_id, session_id, source, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(aid, tid, uid, sid, src, ts),
)
def attachment_acl_allows_user(self, *, tenant_id: str, user_id: str, attachment_id: str) -> bool:
tid = str(tenant_id or "").strip()
uid = str(user_id or "").strip()
aid = str(attachment_id or "").strip().lower()
if not tid or not uid or not aid:
return False
with self._connect() as conn:
r = conn.execute(
"""
SELECT 1
FROM attachment_acl
WHERE tenant_id = ? AND user_id = ? AND attachment_id = ?
LIMIT 1
""",
(tid, uid, aid),
).fetchone()
return bool(r)
def attachment_acl_allows_tenant(self, *, tenant_id: str, attachment_id: str) -> bool:
tid = str(tenant_id or "").strip()
aid = str(attachment_id or "").strip().lower()
if not tid or not aid:
return False
with self._connect() as conn:
r = conn.execute(
"""
SELECT 1
FROM attachment_acl
WHERE tenant_id = ? AND attachment_id = ?
LIMIT 1
""",
(tid, aid),
).fetchone()
return bool(r)
def backfill_attachment_acl_from_messages(
self,
*,
tenant_id: str,
limit_messages: int = 50_000,
) -> dict[str, Any]:
"""Best-effort backfill: scan chat_message.attachments and populate attachment_acl.
This is intended for one-off migration / operator maintenance.
"""
tid = str(tenant_id or "").strip()
lim = max(1, int(limit_messages))
if not tid:
return {"ok": False, "error": "tenant_id_required"}
inserted = 0
scanned_msgs = 0
scanned_atts = 0
with self._connect() as conn:
rows = conn.execute(
"""
SELECT m.session_id, m.attachments, o.user_id
FROM chat_message m
INNER JOIN ui_session_owner o ON o.session_id = m.session_id
WHERE o.tenant_id = ? AND m.attachments IS NOT NULL AND m.attachments <> ''
ORDER BY m.id DESC
LIMIT ?
""",
(tid, lim),
).fetchall()
ts = utc_now_iso()
for r in rows:
scanned_msgs += 1
sid = str(r["session_id"] or "").strip()
uid = str(r["user_id"] or "").strip()
if not sid or not uid:
continue
try:
obj = json.loads(str(r["attachments"] or ""))
except Exception:
continue
items = obj if isinstance(obj, list) else ([obj] if isinstance(obj, dict) else [])
for a in items:
if not isinstance(a, dict):
continue
scanned_atts += 1
aid = str(a.get("attachment_id") or "").strip().lower()
if not aid:
continue
src = "backfill:chat_message"
cur = conn.execute(
"""
INSERT OR IGNORE INTO attachment_acl
(attachment_id, tenant_id, user_id, session_id, source, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(aid, tid, uid, sid, src, ts),
)
inserted += int(cur.rowcount or 0)
return {
"ok": True,
"tenant_id": tid,
"scanned_messages": int(scanned_msgs),
"scanned_attachments": int(scanned_atts),
"inserted": int(inserted),
}
def list_admin_sessions(
self,
*,