diff options
| -rw-r--r-- | docs/MESHBAY_DESIGN.md | 47 | ||||
| -rw-r--r-- | packages/meshbay-client/src/main.js | 19 | ||||
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/paths.py | 8 | ||||
| -rw-r--r-- | packages/meshbay-common/tests/test_paths.py | 7 | ||||
| -rw-r--r-- | packages/meshbay-common/tests/test_portable_name_parity.py | 1 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/portable-name.js | 7 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_desktop_shell.py | 23 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/roots.py | 15 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc/upload_handlers.py | 12 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_partial_uploads.py | 21 |
10 files changed, 144 insertions, 16 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index 1f2e43d..a43e35d 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -1621,7 +1621,11 @@ reason to show progress. Five protections, and they are the substance: -- a **filename allowlist**; +- a **filename allowlist**, and **no file Windows Explorer acts on by itself** — + `desktop.ini`, `.lnk`, `.url`, `.scf`, `.library-ms`, `.searchConnector-ms`. Shown in + a folder the operator browses, any of them can make Explorer contact a server + of the uploader's choosing with the operator's Windows credentials; refused on + every node, since a Linux node's folder may be shared to Windows; - **no overwrite** — a colliding name gets a free one. The check is `Path.exists()`, and `stat()` is itself case-insensitive on NTFS and exFAT, so this already holds there. A name an upload in flight will take counts as taken, since its file @@ -2324,15 +2328,29 @@ The rules that make this safe: CONFLICT DO UPDATE … WHERE … RETURNING` — so a concurrent burst gets no more attempts than the limit. A request that checked no passphrase gives its attempt back. -- **Every path that checks the passphrase counts on the same row**: sign-in, - passphrase change, changing the e-mail address on file and account deletion. A - right passphrase clears it; failures older than the window age out. +- **A browser the account signed in from has a row of its own.** A successful + sign-in from a browser that presented no token is answered with one (`known_browser`, + a random value the hub keeps only hashed, at most twenty per account, the least + recently used going first); a later sign-in presenting it is counted on its own + row, which nobody else can spend. The username's row, which anyone can spend, + then locks only browsers the account has never used. The token is not a + credential — the passphrase is still checked, at the same rate — and a token + for another account counts on the name's row like no token at all (**M1**). It + survives sign-out by design, and a passphrase reset or the account's erasure + forgets every one. +- **A passphrase re-checked inside an open session counts on the session's + account row** — passphrase change, changing the e-mail address on file, account + deletion, registering a device, asking for the bundle pepper. A stranger + failing at sign-in does not stop the owner doing any of them, and failures there + lock nothing at sign-in. A right passphrase clears the row it was checked on; + failures older than the window age out. - **A lockout refuses passphrase sign-in and nothing else.** Open sessions, token - renewal and device sign-in continue, and a reset code sent to the address on - file clears it — so a stranger who locks a public username costs its owner at - most a new sign-in (**AV26**). A session learns its own lockout from - `/v1/users/me`, because a passphrase change re-wraps every node's bundle before - the hub accepts the new passphrase and must not start when the hub would refuse. + renewal and device sign-in continue, a known browser signs in on its own row, + and a reset code sent to the address on file clears it — so a stranger who + locks a public username costs its owner at most a sign-in from a new browser + (**AV26**). A session learns its own row's lockout from `/v1/users/me`, because + a passphrase change re-wraps every node's bundle before the hub accepts the new + passphrase and must not start when the hub would refuse. --- @@ -2433,6 +2451,11 @@ What running it establishes, and what each fact costs: - **OS-backed secret storage is real on a desktop and honest without one.** With a keyring it is keyring-backed; headless, the same code reports unavailable and **refuses to store rather than downgrading silently**. +- **A downloaded file carries the Mark-of-the-Web**, as a browser's download + does: on Windows the application writes `Zone.Identifier` (ZoneId 3) beside each + file it saves, so SmartScreen and Protected View apply when it is opened. The + application writes its files itself, so nothing else would mark them; FAT and + exFAT have no such stream. - **Installation places files, never secrets.** No key generation in a package's post-install step or an installer custom action — a golden image would give every machine the same key. @@ -3218,7 +3241,9 @@ requirements, not compatibility notes. the name its disk gave the file and never rewrites it — that is the string that opens it — so a single file, a zip's entries and the zip's own name are passed through `portable-name.js` at the moment of saving (reserved characters become -`_`, a trailing dot or space goes, a reserved stem gains `_`), and the transfer's +`_` — the bidirectional controls among them, since `invoice\u202efdp.exe` displays +as `invoiceexe.pdf` — a trailing dot or space goes, a reserved stem gains `_`), and +the transfer's row names the original when it changed. The rule is `paths.sanitize_for_download`, and the two are held byte-identical by a parity test. Two different names can still become one — a zip keeps both entries under it. @@ -3561,7 +3586,7 @@ had already been asked. | **AV23** | **An upload's owner is recorded when the upload ends and applied when the entry is created**, which are different moments (§5.4). Written against the index at the end of the upload it matched nothing, every time, and left every uploaded file owned by nobody — so no member could delete what they had sent | | **AV24** | **A node registered for no group is refused signaling, not exempted from it** (§7.2). The membership check was written as "if the node claims any group", so it skipped itself — membership, group status and the public-group gate together — for the node AV1 made commonplace: the unconfigured one, which is also the one least able to absorb the work | | **AV25** | **Which nodes host a group is answered to its members** (§7.3). Only the public case checked, so a private group told any authenticated account that knew its id which machines hosted it — and an ex-member knows that id for ever | -| **AV26** | **A sign-in lockout refuses passphrase sign-in and nothing else** (§7.7). It is keyed by username, usernames are public, and so anyone can spend somebody else's attempts. Open sessions, renewal and device sign-in are untouched and a reset code ends it, which bounds what a stranger buys to one forced sign-in. The lockout is a DoS primitive by construction; this is the ceiling on it | +| **AV26** | **A sign-in lockout refuses passphrase sign-in and nothing else** (§7.7). Its username row is public, so anyone can spend it; a browser the account has signed in from counts on a row of its own, and passphrase checks inside a session on the account's. Open sessions, renewal and device sign-in are untouched and a reset code ends it, which bounds what a stranger buys to a sign-in from a browser the account never used | | **AV27** | **A free-text third-party search is bounded per member and per node** (§6.5). `tmdb_search_req` spends the *operator's* credential, which TMDB rates and the whole group's automatic matching depends on, so one member holding a search box degrades the library for everyone. Per member and not per connection — three tabs is one person — and kept in the group context so a reconnect does not reset it. The refusal is an error, because an empty result list is what "no such film" looks like | | **AV29** | **An invitation link is bounded on both halves and its mail on the sender** (§3.4, §7.3). Twenty outstanding per group on the node (bearer codes) and on the hub (tickets); and because a link mail reaches an address the hub has no relationship with, at the request of anyone who owns a group, it is counted **per sending account per day** (`mail.invite_link_daily_cap`, 10), under the recipient and instance bounds and outside the recovery reserve (`invite_link` is not a recovery purpose) | | **AV28** | **How many node keys one account may announce is bounded** (§7.2). Each is a row plus an IP-log row under a one-year retention, so an account in a loop writes a year of storage on the operator's disk having paid only for signatures. Proof of possession (**M8**) settles whose key it is and not how many. Counted only where a row is added: re-announcing a key already held keeps working at the ceiling, or a node that reached it could never refresh its address again | diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index 236a285..52ca168 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -1160,8 +1160,24 @@ function registerBridge() { return { path: chosen, name: path.basename(chosen) }; }); + // The Mark-of-the-Web, as a browser leaves on every download: the file came + // from somebody else's machine, and Windows decides what that means — + // SmartScreen for a program, Protected View for a document. This application + // writes its files itself, so nothing else marks them. NTFS only; elsewhere + // there is no such stream, and nothing is lost by not having one. + function markFromInternet(file) { + if (process.platform !== 'win32') return; + try { + fs.writeFileSync(`${file}:Zone.Identifier`, '[ZoneTransfer]\r\nZoneId=3\r\n'); + } catch { /* FAT, exFAT, a network share: no alternate data streams */ } + } + handle('save:begin', async (_e, suggestedName, opts) => { - const wanted = path.basename(String(suggestedName || 'download')); + // Bidirectional controls replaced here as well as in the page + // (portable-name.js): "invoice\u202efdp.exe" would be saved, and listed by + // the file manager, as "invoiceexe.pdf". + const wanted = path.basename(String(suggestedName || 'download')) + .replace(/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, '_'); const chosen = chosenDownloadDir(); let target = null; @@ -1231,6 +1247,7 @@ function registerBridge() { console.error('[MeshBay] could not finalise download:', err.message); return false; } + markFromInternet(sink.path); completedPaths.set(String(id), sink.path); return true; }); diff --git a/packages/meshbay-common/src/meshbay_common/paths.py b/packages/meshbay-common/src/meshbay_common/paths.py index b673649..8be4680 100644 --- a/packages/meshbay-common/src/meshbay_common/paths.py +++ b/packages/meshbay-common/src/meshbay_common/paths.py @@ -30,8 +30,12 @@ WINDOWS_RESERVED = frozenset({ *(f"LPT{i}" for i in range(1, 10)), }) -# Reserved on Windows; `/` is reserved everywhere. Control characters go too. -_RESERVED_CHARS = set('<>:"/\\|?*') | {chr(c) for c in range(32)} +# Reserved on Windows; `/` is reserved everywhere. Control characters go too, +# and so do the bidirectional controls: "invoice\u202efdp.exe" displays as +# "invoiceexe.pdf", and a saved name must say what the file is. +BIDI_CONTROLS = frozenset("\u061c\u200e\u200f\u202a\u202b\u202c\u202d\u202e" + "\u2066\u2067\u2068\u2069") +_RESERVED_CHARS = set('<>:"/\\|?*') | {chr(c) for c in range(32)} | BIDI_CONTROLS # Windows without long-path support. A deep media library reaches this. MAX_PATH_WINDOWS = 260 diff --git a/packages/meshbay-common/tests/test_paths.py b/packages/meshbay-common/tests/test_paths.py index 223a2a6..012a32e 100644 --- a/packages/meshbay-common/tests/test_paths.py +++ b/packages/meshbay-common/tests/test_paths.py @@ -119,3 +119,10 @@ def test_sanitizing_produces_something_writable(): def test_sanitizing_never_returns_nothing(): assert sanitize_for_download("...") not in ("", None) assert sanitize_for_download("???") not in ("", None) + + +def test_a_bidi_override_cannot_hide_an_extension(): + from meshbay_common.paths import portable_name_problem, sanitize_for_download + disguised = "invoicefdp.exe" # displays as "invoiceexe.pdf" + assert sanitize_for_download(disguised) == "invoice_fdp.exe" + assert portable_name_problem(disguised) diff --git a/packages/meshbay-common/tests/test_portable_name_parity.py b/packages/meshbay-common/tests/test_portable_name_parity.py index 3a491a7..d7df710 100644 --- a/packages/meshbay-common/tests/test_portable_name_parity.py +++ b/packages/meshbay-common/tests/test_portable_name_parity.py @@ -26,6 +26,7 @@ pytestmark = pytest.mark.skipif( ) NAMES = [ + "invoice\u202efdp.exe", "a\u2066b\u2069.txt", "mark\u200f.txt", "plain.txt", "Réunion 12:30.pdf", 'a<b>c:d"e/f\\g|h?i*j.txt', "tab\tnew\nline", "ends with dot.", "ends with space ", "trailing . . ", "CON", "con.txt", "aux.tar.gz", "COM1", "com10.txt", "LPT9.log", "nul.", ".", "..", "", " ", ".bashrc", "...", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/portable-name.js b/packages/meshbay-hub/src/meshbay_hub/static/portable-name.js index bb2438c..34085ae 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/portable-name.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/portable-name.js @@ -20,8 +20,13 @@ const WINDOWS_RESERVED = new Set([ ]); const RESERVED_CHARS = new Set('<>:"/\\|?*'); +// The bidirectional controls: "invoice\u202efdp.exe" displays as +// "invoiceexe.pdf", and a saved name must say what the file is. +const BIDI_CONTROLS = new Set('\u061c\u200e\u200f\u202a\u202b\u202c\u202d\u202e' + + '\u2066\u2067\u2068\u2069'); -const reserved = (c) => RESERVED_CHARS.has(c) || c.charCodeAt(0) < 32; +const reserved = (c) => RESERVED_CHARS.has(c) || BIDI_CONTROLS.has(c) + || c.charCodeAt(0) < 32; function isPortable(name) { if (!name || name === '.' || name === '..') return false; diff --git a/packages/meshbay-hub/tests/test_desktop_shell.py b/packages/meshbay-hub/tests/test_desktop_shell.py index 4426dd7..60aa32f 100644 --- a/packages/meshbay-hub/tests/test_desktop_shell.py +++ b/packages/meshbay-hub/tests/test_desktop_shell.py @@ -701,3 +701,26 @@ def test_an_account_made_in_the_application_starts_without_browser_access(): assert "platform.keys.createdHere(reg.userId)" in register assert "userId" in (STATIC / "keyderive.js").read_text(encoding="utf-8").split( "async function registerUser", 1)[1].split("\n}\n", 1)[0] + + +def _handler(name: str) -> str: + source = _main() + start = source.index(f"handle('{name}'") + return source[start:source.index("\n });", start)] + + +def test_a_finished_download_carries_the_mark_of_the_web(): + """What a browser leaves on every download, so Windows applies SmartScreen + and Protected View. Only checkable here by reading: the stream exists on + NTFS alone, and this suite does not run on Windows.""" + assert "markFromInternet(sink.path)" in _handler("save:end") + source = _main() + mark = source[source.index("function markFromInternet"):] + mark = mark[:mark.index("\n }\n")] + assert "Zone.Identifier" in mark and "ZoneId=3" in mark + assert "process.platform !== 'win32'" in mark + + +def test_a_saved_name_cannot_hide_its_extension(): + begin = _handler("save:begin") + assert "\\u202a-\\u202e" in begin and "\\u2066-\\u2069" in begin diff --git a/packages/meshbay-node/src/meshbay_node/roots.py b/packages/meshbay-node/src/meshbay_node/roots.py index a4f9cc9..2de0708 100644 --- a/packages/meshbay-node/src/meshbay_node/roots.py +++ b/packages/meshbay-node/src/meshbay_node/roots.py @@ -53,6 +53,21 @@ SAFE_UPLOAD_NAME = re.compile( re.UNICODE) +# Files Windows Explorer acts on by itself when it shows a folder: a link's +# icon, a folder's settings, a search connector. Placed by a member in a folder +# the operator browses, any of them can make Explorer contact a server of the +# member's choosing with the operator's Windows credentials — a known attack, +# and why mail providers refuse the same types. Refused for every node: a +# Linux node's folder may be shared to Windows machines. +SHELL_ACTIVE_NAMES = frozenset({"desktop.ini"}) +SHELL_ACTIVE_SUFFIXES = (".lnk", ".url", ".scf", ".library-ms", ".searchconnector-ms") + + +def shell_active(filename: str) -> bool: + name = filename.lower() + return name in SHELL_ACTIVE_NAMES or name.endswith(SHELL_ACTIVE_SUFFIXES) + + def _free_name(directory: Path, filename: str, taken: frozenset[str] | set[str] = frozenset()) -> str: """ diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/upload_handlers.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/upload_handlers.py index f1ed139..02a50af 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/upload_handlers.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/upload_handlers.py @@ -8,7 +8,14 @@ from pathlib import Path from meshbay_common.protocol import UPLOAD_PROBE_INDEX, file_upload_ack_wire, file_upload_payload from meshbay_node import uploads as uploads_mod -from meshbay_node.roots import SAFE_UPLOAD_NAME, RootSet, _free_name, off_disk, publish_upload +from meshbay_node.roots import ( + SAFE_UPLOAD_NAME, + RootSet, + _free_name, + off_disk, + publish_upload, + shell_active, +) from meshbay_node.transport.webrtc.disk import _append_chunk from meshbay_node.transport.webrtc.limits import LEASE_NONE, LEASE_QUEUED @@ -212,6 +219,9 @@ class UploadMixin: if not SAFE_UPLOAD_NAME.match(filename): _refuse("Invalid filename", "invalid_filename") return + if shell_active(filename): + _refuse("This type of file is not accepted", "file_type_refused") + return roots: RootSet | None = ctx.get("roots") if not roots: diff --git a/packages/meshbay-node/tests/test_partial_uploads.py b/packages/meshbay-node/tests/test_partial_uploads.py index 138ee3b..556b26a 100644 --- a/packages/meshbay-node/tests/test_partial_uploads.py +++ b/packages/meshbay-node/tests/test_partial_uploads.py @@ -22,6 +22,7 @@ import time import types from pathlib import Path +import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from meshbay_common.crypto import generate_gek from meshbay_common.protocol import ( @@ -552,3 +553,23 @@ def test_without_hard_links_a_file_is_still_not_replaced(tmp_path, monkeypatch): assert (tmp_path / "a.txt").read_bytes() == b"there first" assert (tmp_path / "a (2).txt").read_bytes() == b"uploaded" assert not part.exists() + + +# ── files Explorer acts on by itself ───────────────────────────────────────── + + +@pytest.mark.parametrize("name", ["desktop.ini", "Desktop.INI", "photos.lnk", "site.url", + "x.scf", "Docs.library-ms", "s.searchConnector-ms"]) +async def test_a_file_explorer_acts_on_is_refused(tmp_path, name): + ctx = _group_ctx(tmp_path) + peer = _peer(ctx) + await peer._do_file_upload(sealed_upload(peer, filename=name, data=b"[x]")) + assert [m.get("code") for m in _errors(peer)] == ["file_type_refused"] + assert not any(ctx["roots"].roots[0].path.iterdir()) + + +async def test_an_ordinary_file_with_a_near_name_is_accepted(tmp_path): + ctx = _group_ctx(tmp_path) + peer = _peer(ctx) + await peer._do_file_upload(sealed_upload(peer, filename="url-notes.txt", data=b"x")) + assert _errors(peer) == [] |