diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 159 |
1 files changed, 35 insertions, 124 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 1f1f2d2..947d1f9 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -28,7 +28,6 @@ import hashlib import hmac import logging import os -import re import struct import time from pathlib import Path @@ -86,8 +85,9 @@ from meshbay_common.webcrypto import chunk_key_aes, encrypt_chunk_aes from meshbay_common.protocol import MNP from meshbay_node.indexer import GroupIndex from meshbay_node import ops -from meshbay_node.roots import RootSet, entry_abs_path -from meshbay_node.roster import DEFAULT_INVITE_TTL +from meshbay_node.roots import ( + RootSet, entry_abs_path, SAFE_UPLOAD_NAME, safe_subdir, _free_name, +) log = logging.getLogger(__name__) @@ -135,81 +135,6 @@ JOIN_FAILURE_WINDOW = 600 # seconds # into, back up or empty — rather than a hidden tree of per-user uuids that # nobody could read, or files scattered wherever someone happened to be looking. UPLOAD_DIR_NAME = "uploads" -# Conservative allowlist: also what keeps markup out of filenames, which the node admin -# UI used to render unescaped (finding H2). -# An allowlist, still — C5a and H2 depend on it — but one that does not assume -# the world writes in ASCII. `été.txt` and `rapport (1).pdf` were refused, and -# the second of those is a name _free_name generates itself, so the node was -# rejecting files it had named. `\w` is Unicode here, which admits letters and -# digits of any script while `<`, `>`, `"`, `;`, `/`, `\` and control characters -# stay out. The first character must be a letter or digit, so ".." and dotfiles -# cannot start one, and a trailing space or dot is refused because it makes two -# different files look identical in a list. -SAFE_UPLOAD_NAME = re.compile( - r"^[^\W_]" # letter or digit — never '.', '-' or space - r"[\w .\-()\[\]'\u2019,&+#@]{0,127}" # body: word chars plus mild punctuation - r"(?<![ .])$", # and never ending on a space or a dot - re.UNICODE) - - -def _free_name(directory: Path, filename: str) -> str: - """ - `filename`, or the first "name (n).ext" that is not taken. - - Never returns the name of a file that exists, so an upload cannot replace - one — the property the per-user quarantine used to provide (C5a). - """ - if not (directory / filename).exists(): - return filename - stem, dot, ext = filename.rpartition(".") - if not dot: - stem, ext = filename, "" - for n in range(2, 1000): - candidate = f"{stem} ({n}){dot}{ext}" - if not (directory / candidate).exists(): - return candidate - raise FileExistsError(filename) - - -def safe_subdir(roots: RootSet, rel: str) -> Path | None: - """ - Resolve a client-supplied directory inside one of the group's roots, or refuse. - - The path arrives from the wire, so every part is checked: the first segment - must name a root that is readable right now, each later segment against the - same allowlist as filenames, and the resolved result against that root's - directory. `..`, absolute paths, symlinks pointing out, and anything with a - separator in a segment are all refused here rather than in the caller, so - there is one place to get it right. - - The virtual root itself — `""` — is deliberately **not** resolvable. It is - not a directory on anyone's disk: a file cannot be written there and a - directory cannot be created there, because it belongs to no volume. Callers - that used to receive the shared root for an empty path now receive None, - which is the honest answer. - - The quarantine was the fix for C5a; what actually mattered in it — no - overwrite, a name allowlist, and confinement — is kept by this plus the - caller's existing checks. - """ - found = roots.split(rel or "") - if found is None: - return None - root, tail = found - if not root.available: - return None - parts = [seg for seg in tail.split("/") if seg not in ("", ".")] - if any(seg == ".." or not SAFE_UPLOAD_NAME.match(seg) for seg in parts): - return None - try: - target = (root.path / Path(*parts)).resolve() if parts else root.path.resolve() - base = root.path.resolve() - except OSError: - return None - if target != base and base not in target.parents: - return None - return target - def _extract_dtls_fingerprint(sdp: str) -> bytes: @@ -985,6 +910,12 @@ class WebRTCPeerSession: # the client is told it has no role on a node it administers. member = (await roster.get_member(group_id, user_id) or await roster.get_member("", user_id)) + if not member and self._group_join_policy(session_group) == "open": + await roster.set_member( + group_id=session_group, user_id=user_id, role=ROLE_MEMBER, + status="active", approved_by="open-join", + ) + member = await roster.get_member(session_group, user_id) await self._join_ok( user_id, pk_x_raw, session_group, role=member["role"] if member else "", @@ -1635,16 +1566,12 @@ class WebRTCPeerSession: self._send({"type": "error", "detail": "Signature verification failed"}) self._audit("admin_auth_failed", f"member_upload:{pending['subject']}") return - roster = self._ctx.get("roster") - if roster is None: - self._send({"type": "error", "detail": "No roster on this node"}) + try: + await self._run_op( + ops.set_member_upload, self._group_id or "", allowed) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) return - await roster.set_member_upload(self._group_id or "", allowed, - set_by=self._user_id) - # Stored *and* applied. The upload path is synchronous and reads this - # dict; leaving it to the next restart would make the panel say one - # thing while the node did another. - self._group_ctx()["member_upload"] = allowed self._audit("member_upload", pending["subject"]) # Everyone already connected is told, rather than finding out by having @@ -1928,14 +1855,11 @@ class WebRTCPeerSession: self._audit("admin_auth_failed", f"member_revoke:{user_id[:8]}") return - roster = self._ctx.get("roster") - if roster is None: - self._send({"type": "error", "detail": "Roster not available"}) - return - - group_id = self._group_id or "" - if not await roster.set_status(group_id, user_id, "revoked"): - self._send({"type": "error", "detail": "Not a member of this group"}) + try: + result = await self._run_op( + ops.revoke_member, user_id, self._group_id or "") + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) return # Anyone connected right now keeps the key they already unwrapped; what @@ -1948,14 +1872,11 @@ class WebRTCPeerSession: except Exception: pass - log.info("Member revoked by %s: user=%s group=%s", - self._user_id[:8], user_id[:8], group_id[:8] or "-") self._audit("member_revoke", user_id) self._send({ "type": MNP.MEMBER_REVOKE_ACK, "v": MNP_VERSION, "user_id": user_id, - "reminder": "they still hold the current group key — rotate it with " - "meshbay-node gek-init", + "reminder": result.get("reminder", ""), }) async def _do_keypair_bundle_delete(self) -> None: @@ -2694,37 +2615,27 @@ class WebRTCPeerSession: self._audit("admin_auth_failed", f"invite_create:{pending['subject'][:16]}") return - roster = self._ctx.get("roster") - if roster is None: - self._send({"type": "error", "detail": "Roster not available"}) - return - payload = pending["payload"] - code = await roster.create_invite( - group_id=payload["group_id"], - user_id=payload["user_id"], - role=ROLE_MEMBER, - created_by=self._user_id or "", - ttl=self._ctx.get("invite_ttl", DEFAULT_INVITE_TTL), - username=payload.get("username", ""), - ) - invites = await roster.list_invites() - expires = next( - (i["expires_at"] for i in invites - if i["user_id"] == payload["user_id"] - and i["group_id"] == payload["group_id"]), "") + try: + result = await self._run_op( + ops.create_invite, + payload["group_id"], + payload.get("username", ""), + user_id=payload["user_id"], + created_by=self._user_id or "", + ) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return - log.info("Invite created: group=%s user=%s", - payload["group_id"][:8], payload["user_id"][:8]) self._audit("invite_create", f"target={payload['user_id'][:8]}") - # The code exists in the clear exactly here and in the operator's hands. self._send({ "type": MNP.INVITE_RESULT, "v": MNP_VERSION, - "code": code, - "expires_at": expires, - "user_id": payload["user_id"], - "username": payload.get("username", ""), + "code": result["code"], + "expires_at": result["expires_at"], + "user_id": result["user_id"], + "username": result.get("username", ""), }) def _exec_file_delete(self, ctx: dict, file_id: str, entry) -> None: |