summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-01 11:47:39 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-01 11:47:39 +0200
commit760ac421b1944cd69a80e3a92127a1a966f15938 (patch)
treec894b655d4f21698ebb91c0865ddf3e04985586d
parent752b160c7c5e671e0db8f402a52fac27bb85ab06 (diff)
downloadmeshbay-760ac421b1944cd69a80e3a92127a1a966f15938.tar.gz
fix: downloads are marked and keep their extension; Explorer files are refused
The desktop app writes the Mark-of-the-Web on each file it saves on Windows, as a browser does. Bidirectional controls are reserved characters in a saved name (portable-name.js and paths.sanitize_for_download, and again in the main process), so a name cannot display one extension and carry another. The node refuses uploads of files Windows Explorer acts on by itself: desktop.ini, .lnk, .url, .scf, .library-ms, .searchConnector-ms (F-19). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--docs/MESHBAY_DESIGN.md47
-rw-r--r--packages/meshbay-client/src/main.js19
-rw-r--r--packages/meshbay-common/src/meshbay_common/paths.py8
-rw-r--r--packages/meshbay-common/tests/test_paths.py7
-rw-r--r--packages/meshbay-common/tests/test_portable_name_parity.py1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/portable-name.js7
-rw-r--r--packages/meshbay-hub/tests/test_desktop_shell.py23
-rw-r--r--packages/meshbay-node/src/meshbay_node/roots.py15
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/upload_handlers.py12
-rw-r--r--packages/meshbay-node/tests/test_partial_uploads.py21
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 = "invoice‮fdp.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) == []