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

@ -7,6 +7,8 @@ import json
import queue
import threading
import os
import re
import time
from collections.abc import Callable, Iterator
from typing import Any
from pathlib import Path
@ -66,6 +68,11 @@ _CHAT_MSG_LIMIT = 256
_SESSION_TITLE_MAX_LEN = 120
_AVATAR_UPLOAD_MAX_BYTES = 2 * 1024 * 1024
_AVATAR_MIMES = frozenset({"image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"})
_ATTACHMENT_ID_RE = re.compile(r"^[a-f0-9]{64}$")
_ATT_DOWNLOAD_BUCKET: dict[str, tuple[float, float]] = {}
_ATT_DOWNLOAD_LOCK = threading.Lock()
_ATT_DOWNLOAD_RATE_PER_SEC = 1.0 # 60/min
_ATT_DOWNLOAD_BURST = 20.0
_CHAT_STOP_EVENTS: dict[str, threading.Event] = {}
_CHAT_STOP_LOCK = threading.Lock()
@ -252,6 +259,58 @@ def _parse_attachments_payload(raw: Any) -> list[dict[str, Any]] | None:
return out if out else None
def _is_valid_attachment_id(raw: str) -> bool:
return bool(_ATTACHMENT_ID_RE.fullmatch(str(raw or "").strip().lower()))
def _can_access_attachment(store: SqliteStore, ctx: dict[str, Any], *, attachment_id: str) -> bool:
aid = str(attachment_id or "").strip().lower()
if not _is_valid_attachment_id(aid):
return False
tenant_id = str(ctx.get("tenant_id") or "").strip()
user_id = str(ctx.get("user_id") or "").strip()
if not tenant_id:
return False
if _is_administrator_chat_viewer(ctx):
if store.attachment_acl_allows_tenant(tenant_id=tenant_id, attachment_id=aid):
return True
if (not _attachment_acl_strict()) and store.attachment_referenced_in_tenant(tenant_id=tenant_id, attachment_id=aid):
return True
# Keep profile/avatar behavior compatible for administrator account.
admin = store.get_user_by_username(tenant_id=tenant_id, username="administrator") or {}
return str(admin.get("avatar_attachment_id") or "").strip().lower() == aid
if user_id and store.attachment_acl_allows_user(tenant_id=tenant_id, user_id=user_id, attachment_id=aid):
return True
if (not _attachment_acl_strict()) and user_id and store.attachment_referenced_by_user(tenant_id=tenant_id, user_id=user_id, attachment_id=aid):
return True
user = store.get_user_by_id(tenant_id=tenant_id, user_id=user_id) if user_id else None
return str((user or {}).get("avatar_attachment_id") or "").strip().lower() == aid
def _rate_limit_attachment_download(*, actor_tenant_id: str, actor_user_id: str) -> bool:
tid = str(actor_tenant_id or "").strip()
uid = str(actor_user_id or "").strip()
if not tid or not uid:
return False
key = f"{tid}:{uid}"
now = time.time()
with _ATT_DOWNLOAD_LOCK:
tokens, last = _ATT_DOWNLOAD_BUCKET.get(key, (_ATT_DOWNLOAD_BURST, now))
dt = max(0.0, now - float(last or now))
tokens = min(_ATT_DOWNLOAD_BURST, float(tokens) + dt * _ATT_DOWNLOAD_RATE_PER_SEC)
if tokens < 1.0:
_ATT_DOWNLOAD_BUCKET[key] = (tokens, now)
return False
tokens -= 1.0
_ATT_DOWNLOAD_BUCKET[key] = (tokens, now)
return True
def _attachment_acl_strict() -> bool:
raw = str(os.getenv("AIA_ATTACHMENT_ACL_STRICT") or "").strip().lower()
return raw in {"1", "true", "yes", "on"}
def _chat_username(ctx: dict[str, Any]) -> str:
return str(ctx.get("username") or "").strip().lower()
@ -1448,18 +1507,86 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
authorization: str | None = Header(default=None),
) -> Response:
store = SqliteStore(db_path())
_ = resolve_auth(store, authorization)
aid = str(attachment_id or "").strip()
ctx = resolve_auth(store, authorization)
tenant_id = str(ctx.get("tenant_id") or "").strip()
user_id = str(ctx.get("user_id") or "").strip()
aid = str(attachment_id or "").strip().lower()
if not aid:
raise HTTPException(status_code=400, detail="attachment_id_required")
if not _is_valid_attachment_id(aid):
raise HTTPException(status_code=400, detail="attachment_id_invalid")
if not _rate_limit_attachment_download(actor_tenant_id=tenant_id, actor_user_id=user_id):
store.add_admin_audit_log(
actor_tenant_id=tenant_id,
actor_user_id=user_id,
action="chat_attachment_download",
target_type="attachment",
target_id=aid,
status="rate_limited",
detail={"path": "chat_api.attachment_bytes"},
)
raise HTTPException(status_code=429, detail="rate_limited")
if not _can_access_attachment(store, ctx, attachment_id=aid):
store.add_admin_audit_log(
actor_tenant_id=tenant_id,
actor_user_id=user_id,
action="chat_attachment_download",
target_type="attachment",
target_id=aid,
status="forbidden",
detail={"path": "chat_api.attachment_bytes"},
)
raise HTTPException(status_code=403, detail="attachment_forbidden")
ast = AttachmentAssetStore()
try:
blob, meta = ast.load_bytes(aid)
except Exception:
store.add_admin_audit_log(
actor_tenant_id=tenant_id,
actor_user_id=user_id,
action="chat_attachment_download",
target_type="attachment",
target_id=aid,
status="not_found",
detail={"path": "chat_api.attachment_bytes"},
)
raise HTTPException(status_code=404, detail="attachment_not_found") from None
mime = (meta.mime if meta else None) or "application/octet-stream"
store.add_admin_audit_log(
actor_tenant_id=tenant_id,
actor_user_id=user_id,
action="chat_attachment_download",
target_type="attachment",
target_id=aid,
status="ok",
detail={"path": "chat_api.attachment_bytes", "mime": mime, "bytes": int(meta.bytes if meta else len(blob))},
)
return Response(content=blob, media_type=mime)
@chat.post("/admin/attachments/acl/backfill")
def api_chat_admin_backfill_attachment_acl(
limit_messages: int = Query(default=50_000, ge=1, le=500_000),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_administrator_chat_viewer(ctx)
tenant_id = str(ctx.get("tenant_id") or "").strip()
user_id = str(ctx.get("user_id") or "").strip()
res = store.backfill_attachment_acl_from_messages(tenant_id=tenant_id, limit_messages=int(limit_messages))
if bool(res.get("ok")):
res.setdefault("recommended_next", "Set AIA_ATTACHMENT_ACL_STRICT=1 after verifying downloads.")
store.add_admin_audit_log(
actor_tenant_id=tenant_id,
actor_user_id=user_id,
action="attachment_acl_backfill",
target_type="tenant",
target_id=tenant_id,
status="ok" if bool(res.get("ok")) else "error",
detail=res,
)
return res
@chat.post("/sessions/{session_id}/messages")
def api_chat_send(
session_id: str,
@ -1507,6 +1634,20 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
text = _effective_user_text(text=text_raw, attachments=attachments, store=store)
if not _is_administrator_chat_viewer(ctx) and tenant_id and user_id:
store.ensure_ui_session_owner(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
# Record attachment ownership for access control (download endpoint).
if attachments and tenant_id and user_id:
for a in attachments:
if not isinstance(a, dict):
continue
aid = str(a.get("attachment_id") or "").strip().lower()
if aid and _is_valid_attachment_id(aid):
store.link_attachment_acl(
tenant_id=tenant_id,
user_id=user_id,
session_id=str(session_id),
attachment_id=aid,
source="user_upload",
)
lang = _api_lang(store)
apply_gateway_mcp_env_to_os()
manager_agent = _init_gateway_executor(
@ -1611,6 +1752,19 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
text = _effective_user_text(text=text_raw, attachments=attachments, store=store)
if not _is_administrator_chat_viewer(ctx) and tenant_id and user_id:
store.ensure_ui_session_owner(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
if attachments and tenant_id and user_id:
for a in attachments:
if not isinstance(a, dict):
continue
aid = str(a.get("attachment_id") or "").strip().lower()
if aid and _is_valid_attachment_id(aid):
store.link_attachment_acl(
tenant_id=tenant_id,
user_id=user_id,
session_id=str(session_id),
attachment_id=aid,
source="user_upload",
)
lang = _api_lang(store)
apply_gateway_mcp_env_to_os()
manager_agent = _init_gateway_executor(

View file

@ -130,6 +130,11 @@
.chat-sess-menu-item:hover {
background: rgba(255, 255, 255, 0.06);
}
.chat-sess-menu-sep {
height: 1px;
margin: 4px 6px;
background: rgba(255, 255, 255, 0.08);
}
.chat-msg__md {
line-height: 1.45;
word-break: break-word;

View file

@ -148,6 +148,11 @@ const I18N = {
"chat.attachment.previewLoading": "加载中…",
"chat.attachment.previewError": "预览失败",
"chat.attachment.previewEmpty": "(空内容)",
"chat.attachmentAcl": "附件 ACL",
"chat.attachmentAclBackfill": "回填 ACL",
"chat.attachmentAclBackfillPrompt": "回填 attachment_acl(扫描历史消息 attachments)?建议先在低峰期执行。",
"chat.attachmentAclBackfillOk": "回填完成:插入 {inserted} 条(扫描 {scanned_messages} 条消息)",
"chat.attachmentAclBackfillFail": "回填失败:{error}",
},
en: {
"chat.pageTitle": "oliver",
@ -294,6 +299,11 @@ const I18N = {
"chat.attachment.previewLoading": "Loading…",
"chat.attachment.previewError": "Preview failed",
"chat.attachment.previewEmpty": "(empty)",
"chat.attachmentAcl": "Attachment ACL",
"chat.attachmentAclBackfill": "Backfill ACL",
"chat.attachmentAclBackfillPrompt": "Backfill attachment_acl by scanning historical message attachments? Recommended during off-peak hours.",
"chat.attachmentAclBackfillOk": "Backfill done: inserted {inserted} rows (scanned {scanned_messages} messages)",
"chat.attachmentAclBackfillFail": "Backfill failed: {error}",
},
};
@ -2164,6 +2174,9 @@ function syncAuthUserLabel() {
if (!user) return;
user.innerHTML = "";
const name = String((authSession && (authSession.display_name || authSession.username || authSession.user_id)) || "");
const isAdminViewer = String((authSession && authSession.username) || "")
.trim()
.toLowerCase() === "administrator";
if (!name) return;
const nameBtn = el("button", {
type: "button",
@ -2179,7 +2192,7 @@ function syncAuthUserLabel() {
onclick: (ev) => {
ev.stopPropagation();
document.querySelectorAll(".chat-sess-menu-pop").forEach((n) => n.remove());
const menu = el("div", { class: "chat-sess-menu-pop", style: "position:fixed;" }, [
const items = [
el("button", {
type: "button",
class: "chat-sess-menu-item",
@ -2198,13 +2211,28 @@ function syncAuthUserLabel() {
"data-menu-action": "dispatchLabels",
text: t("chat.dispatchLabelsEdit"),
}),
];
if (isAdminViewer) {
items.push(el("div", { class: "chat-sess-menu-sep" }));
items.push(
el("button", {
type: "button",
class: "chat-sess-menu-item",
"data-menu-action": "attachmentAclBackfill",
text: t("chat.attachmentAclBackfill"),
}),
);
}
items.push(el("div", { class: "chat-sess-menu-sep" }));
items.push(
el("button", {
type: "button",
class: "chat-sess-menu-item",
"data-menu-action": "logout",
text: t("auth.logout"),
}),
]);
);
const menu = el("div", { class: "chat-sess-menu-pop", style: "position:fixed;" }, items);
const rect = moreBtn.getBoundingClientRect();
menu.style.left = `${Math.min(rect.left, window.innerWidth - 220)}px`;
menu.style.top = `${Math.max(8, rect.top - 92)}px`;
@ -4394,6 +4422,23 @@ document.body.addEventListener("click", async (e) => {
await openDispatchLabelsEditor(status);
return;
}
if (action === "attachmentAclBackfill") {
const status = document.querySelector(".chat-status");
if (!(await confirmChatAction(t("chat.attachmentAclBackfillPrompt")))) return;
try {
const res = await apiPost("/admin/api/chat/admin/attachments/acl/backfill", {});
const ok = !!(res && (res.ok === true || res.ok === 1));
if (ok) {
if (status) status.textContent = t("chat.attachmentAclBackfillOk", res);
} else {
const err = String((res && (res.error || res.detail)) || "backfill_failed");
if (status) status.textContent = t("chat.attachmentAclBackfillFail", { error: err });
}
} catch (err) {
if (status) status.textContent = t("chat.attachmentAclBackfillFail", { error: String(err) });
}
return;
}
}
});