v5.0.0
Send v5.0.0
41 commits since v4.0.0.
The theme is a smaller trust surface: fewer packages running in your browser,
fewer ways for the server to misbehave, and more of the claims in this
repository checkable rather than asserted.
This is the build that powers send.anoni.net.
Nothing about how Send works has changed
The wire protocol, the URL format and the encryption are untouched. Existing
share links keep working. ffsend and other third-party clients keep working.
/__version__ still reports the API generation in version (v3.4.27) and our
release in release (now v5.0.0).
Files still encrypt in your browser, and the key still never leaves the URL
fragment.
Six server-side defects, found by audit
None of these were reported by a user hitting them. They were found by reading
the code before the release, which is the point of doing it.
Concurrent downloads could exceed the download limit. The count was read,
compared, then incremented, so simultaneous requests for a one-download file all
saw the same value. That matters: for anyone using a one-time link to know
whether a file was fetched twice, the limit is a security property, not a
convenience. Reserving a slot is now a single atomic redis step.
Expired files were never deleted. Redis holds the only expiry TTL. When it
fired, the metadata vanished and the file it pointed at stayed on disk. The FAQ
said files are deleted from the server; now they are. A periodic sweep removes
files whose record is gone.
A crafted WebSocket frame crashed the process. One malformed frame, no
authentication required, stopped the service for everyone. Encrypted metadata
was also unbounded, so a single request could write 100 MiB into redis.
There was no rate limiting at all. Per-IP limits now cover the HTTP API and,
separately and more tightly, the WebSocket upload.
The rate limiter could be switched off by one request header. Introduced by
the fix above and caught in the same audit. The skip for WebSocket upgrades
tested only for the presence of an Upgrade header, so any request carrying one
was served without being counted.
Upload parameters were never type-checked. Every comparison against a
non-numeric value is false, so an expiry of "abc" passed validation and
produced a file that no TTL covered and no sweep would ever match: permanent
storage, obtainable without authentication.
Also removed: an unauthenticated legacy HTTP upload route nothing used, and a
WebSocket fallback address still pointing at Mozilla's shut-down service.
What runs in your browser
The choo framework and its ecosystem are gone, replaced by a small
hand-written framework and event emitter. Fourteen fewer packages reach your
browser. nanohtml and nanomorph are deliberately kept: the first is the
escaping boundary that makes interpolated filenames text instead of markup, and
the second preserves focus while you type a password.
All telemetry and tracking are gone, front and back. Nothing this service
runs reports to anyone else, and the Content-Security-Policy enforces it rather
than promising it.
helmet 3 → 8, with the policy written out in full rather than merged with
library defaults, adding base-uri, script-src-attr, form-action and
object-src. For a service whose security rests on the served JavaScript being
the published JavaScript, the policy that enforces it should be readable in one
place.
The application bundle is 70 KB gzipped. With the locale chunk for your
language, first load is about 74 KB, down from 203 KB at v4.0.0.
Server and build
express 4 → 5. mozlog replaced by a 50-line logger, which unblocks moving
past Node 22 later.
The S3 and Google Cloud SDKs are now optional. They were more than half the
production dependency tree for code a filesystem deployment never loads. 134
packages ship, and npm audit --omit=dev reports zero vulnerabilities in the
image.
The published image now sets NODE_ENV=production. It previously inherited the
development default, which switches off both the CSP and the rate limits, so a
container built from this repository served neither unless its operator knew to
pass the variable.
Verifiability
Lint and both test suites now gate the release itself. They ran on pull
requests but matched neither tag pushes nor main, so the artifact that gets
signed and attested was the one artifact they never ran against.
The release field is read from the source tree alone. It used to fall back
from a dead CircleCI variable. Reaching for the GitHub equivalent would have made
dist/ depend on the build environment and broken the reproducibility guarantee,
so it comes from package.json and a publish-time check requires the git tag to
agree with it.
The build stays reproducible. A clean clone plus npm ci && npm run build
produces dist/ byte-identical to the image, verified for this release across
macOS with Node 26 and Linux with Node 22: all 136 files.
The smoke test now asserts the CSP headers, the rate limit, and that the rate
limit cannot be bypassed by a header, against every image build, alongside the
existing real ffsend encrypted round-trip.
Localization
zh-TW described end-to-end encryption as 點對點加密, the standard rendering of
peer-to-peer. It is corrected to 端對端加密 on the two screens that exist to
explain the guarantee: the home page and every download page.
Verify this release
docker pull ghcr.io/anoni-net/send:5.0.0
cosign verify ghcr.io/anoni-net/send:5.0.0 \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp '^https://github.com/anoni-net/send/.github/workflows/publish.yml@.*$'SHA256SUMS.txt below lists the sha256 of every file the server serves to a
browser, so you can check that what an instance sent you is what we published.
VERIFYING.md walks
through that, the signature, and rebuilding from source.
Pin by digest in production so the tag cannot move under you:
ghcr.io/anoni-net/send@sha256:761dd3bcad90142d5ef2fa687a06be1994bbd7782db196ae3d71bd87ebc6cbae
Known gaps
The pre-release audit found more than was fixed here. What was deliberately
deferred is tracked in
#79, including rate-limit errors
that surface as a generic failure page, a WebSocket connection that hangs
instead of reporting a blocked network, and unsupported-browser pages that still
point at Firefox despite this fork not being affiliated with Mozilla.
Password-protected links derive their key from the full share URL, so the same
link opened on a different hostname reports the password as wrong. This is
inherited from upstream and matters for anyone running a .onion mirror
alongside a clearnet address.
PBKDF2 still uses 100 iterations. The password protects download authorization
only, and its salt already contains the URL secret, so the impact is bounded.
Changing it would change what existing links compute.
Credit
Send was built by Mozilla and kept alive by
Tim Visée after Firefox Send shut down. This
release stands on both. We are not affiliated with, nor endorsed by, either.
Send v5.0.0(正體中文)
自 v4.0.0 起累積 41 個
commit。主軸是縮小信任面:在你瀏覽器裡執行的套件更少、伺服器可能出錯的方式更少,
而這個 repo 宣稱的事情更多是可以查證的,不只是聲明。
這就是 send.anoni.net 所使用的版本。
Send 的運作方式完全沒有改變
傳輸協定、URL 格式與加密都沒有動過。既有的分享連結繼續可用,ffsend 與其他第三方
客戶端也繼續可用。/__version__ 仍然分開回報兩件事,version 是 API 世代
(v3.4.27),release 是我們自己的版號(現在是 v5.0.0)。
檔案依然在你的瀏覽器裡加密,金鑰依然不會離開網址的 fragment。
六個伺服器端缺陷,來自主動稽核
這些全部是在發布前讀程式碼找出來的,沒有一項來自使用者踩到之後的回報。這正是做
稽核的意義。
並發下載可以突破下載次數上限。 原本的作法是先讀取次數、比較、再遞增,所以同時
抵達的請求對一個只允許下載一次的檔案會讀到相同的值。這件事很重要:對任何用一次性
連結來判斷檔案是否被取走兩次的人來說,那個上限是安全性質,不是方便性設定。現在改為
單一個原子的 redis 步驟。
過期的檔案從來沒有被刪除。 redis 持有唯一的過期 TTL。TTL 觸發時 metadata 消失,
但它指向的檔案仍留在磁碟上。FAQ 寫的是檔案會從伺服器刪除,現在才真的如此。定期掃描
會清掉記錄已消失的檔案。
一個構造過的 WebSocket frame 就能讓行程崩潰。 單一個格式錯誤的 frame,不需要
任何認證,就能讓服務對所有人中斷。加密後的 metadata 原本也沒有大小上限,單一個請求
可以往 redis 寫入 100 MiB。
完全沒有速率限制。 現在每個 IP 對 HTTP API 有限制,對 WebSocket 上傳另外有更
嚴格的限制。
速率限制可以被一個請求標頭關掉。 這是上一項的修正帶進來的,在同一次稽核中被
抓到。跳過 WebSocket 升級的判斷只檢查 Upgrade 標頭是否存在,所以任何帶著這個標頭
的請求都會被服務而不計數。
上傳參數從來沒有做型別檢查。 任何與非數字的比較都是 false,所以過期時間送
"abc" 可以通過驗證,並產生一個沒有任何 TTL 覆蓋、也不會被任何掃描匹配到的檔案。
等於未經認證就能取得永久儲存。
另外移除了一個沒有人使用、也不需要認證的舊 HTTP 上傳路徑,以及一個仍指向 Mozilla
已關閉服務的 WebSocket 後備位址。
在你瀏覽器裡執行的東西
choo 框架與它的整套生態已經移除,改用自己寫的小型框架與事件發送器。送到你
瀏覽器的套件少了 14 個。nanohtml 與 nanomorph 是刻意保留的:前者是把插入的
檔名變成文字而非標記的逸出邊界,後者讓你輸入密碼時焦點不會跑掉。
所有追蹤與遙測都已移除,前後端皆然。這個服務執行的任何東西都不會向其他人回報,
而 Content-Security-Policy 是強制執行這件事,不只是承諾。
helmet 3 升到 8,而且政策完整寫出,沒有與程式庫預設值混合,並補上 base-uri、
script-src-attr、form-action 與 object-src。對一個安全性建立在「送出的
JavaScript 就是公開的那份」之上的服務,強制執行這件事的政策應該能在一個地方讀完。
應用程式的 bundle 是 70 KB gzip。加上你語言的語系 chunk,首次載入約 74 KB,v4.0.0
時是 203 KB。
伺服器與建置
express 4 升到 5。mozlog 換成 50 行的 logger,這一步也解開了未來升級到 Node 22
之後的阻礙。
S3 與 Google Cloud 的 SDK 改為選用。它們原本佔正式相依樹一半以上,而檔案系統部署
從來不會載入那些程式碼。現在映像內是 134 個套件,npm audit --omit=dev 回報零個
弱點。
發布的映像現在會設定 NODE_ENV=production。它先前沿用 development 預設值,而那
會同時關掉 CSP 與速率限制,所以從這個 repo 建出來的容器,除非營運者知道要自己傳這
個變數,否則兩者都沒有。
可驗證性
lint 與兩套測試現在會擋住發布本身。 它們原本只在 pull request 上執行,既不匹配
tag 推送也不匹配 main,所以最後被簽章與背書的那個產物,正好是測試套件唯一沒有跑
過的產物。
release 欄位只取自原始碼樹。 它原本會從一個早已失效的 CircleCI 變數退回。改用
GitHub 的對應變數看似直覺,但那會讓 dist/ 取決於建置環境並破壞可重現性保證,所以
它來自 package.json,並由發布時的檢查要求 git tag 與它相符。
建置維持可重現。 乾淨 clone 加上 npm ci && npm run build 產生的 dist/ 與
映像位元一致。這個版本已在 macOS 搭配 Node 26 與 Linux 搭配 Node 22 上驗證,136 個
檔案全部相同。
smoke test 現在會對每一次映像建置斷言 CSP 標頭、速率限制,以及速率限制無法被標頭
繞過,與既有的真實 ffsend 加密往返一起執行。
在地化
zh-TW 把 end-to-end encryption 寫成「點對點加密」,那是 peer-to-peer 的標準中譯。
已更正為「端對端加密」,位置正好是專門用來說明這個保證的兩個畫面:首頁與每一個
下載頁。
驗證這個版本
docker pull ghcr.io/anoni-net/send:5.0.0
cosign verify ghcr.io/anoni-net/send:5.0.0 \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp '^https://github.com/anoni-net/send/.github/workflows/publish.yml@.*$'下方的 SHA256SUMS.txt 列出伺服器會送給瀏覽器的每一個檔案的 sha256,你可以用它
檢查某個實例送給你的東西就是我們發布的東西。
VERIFYING.md 說明這件事、
簽章驗證,以及從原始碼重新建置的作法。
正式環境請用 digest 固定版本,這樣 tag 就不會在你不知情的情況下被移動:
ghcr.io/anoni-net/send@sha256:761dd3bcad90142d5ef2fa687a06be1994bbd7782db196ae3d71bd87ebc6cbae
已知的不足
發布前的稽核找到的東西比這次修掉的多。刻意延後的項目追蹤在
#79,包括速率限制的錯誤會顯示成通用
的失敗頁面、WebSocket 連線在被阻擋時會卡住而不是告訴你網路被擋,以及不支援瀏覽器的
頁面仍然指向 Firefox,儘管這個 fork 與 Mozilla 沒有從屬關係。
密碼保護的連結,其金鑰是從完整分享網址推導的,所以同一個連結在不同主機名稱下開啟
會顯示密碼錯誤。這是從上游繼承的設計,對同時經營 .onion 鏡像與 clearnet 位址的人
特別有影響。
PBKDF2 仍然使用 100 次迭代。密碼只保護下載授權,而它的 salt 已經包含網址裡的
secret,所以影響有限。更動迭代次數會改變既有連結的計算結果。
致謝
Send 由 Mozilla 打造,並在 Firefox Send 關閉之後由
Tim Visée 維持存續。這個版本站在兩者之上。我們
與他們沒有從屬關係,也未獲得他們背書。