URLCompressionWeb APISecurityFrontend

把整個格局塞進網址:不經伺服器的分享連結,實測 511~834 字元,改 1 個字元會怎樣

··閱讀約 11 分鐘

3D 格局工具做好之後,一個常見的疑問是:「這個格局能傳給家人看嗎?」

一般做法是存到伺服器,給一個短網址。我們不想這樣做:工具標榜「檔案不離開你的裝置」,存一份到伺服器就違背了這句話。我們也不想為了分享功能,開始維護資料庫和帳號。

所以分享連結把整份格局放在網址裡。按下「複製分享連結」,畫面會告訴你這條連結有多長:

3D 格局工具的提示訊息:已複製分享連結(678 字元)。連結裡只有牆、家具、風格,不含你上傳的平面圖。
20 坪 3 房範本按「複製分享連結」後的提示。2026-10-16 在正式站截圖,當時是 v=1;v=2 的連結多 5~6 個字元。

為什麼放在 # 後面

網址 # 後面那段叫 fragment(片段)。MDN 寫得很直接:請求網址時,fragment 不會送到伺服器,只由瀏覽器處理。

所以分享連結長這樣:/lab/interior-layout/#v=2&s=…。伺服器只看到 /lab/interior-layout/,收到的是一般的頁面請求。格局資料從頭到尾只在兩個人的瀏覽器裡。

代價也很明確:連結本身就是資料。我們沒辦法撤回一條已經傳出去的連結,拿到連結的人都看得到那個格局。工具在提示裡寫明連結只有牆、家具、風格,不含上傳的平面圖原圖。

第一步:先把 JSON 變小

工具內部的格局資料用公尺、浮點數。直接轉 JSON 會有很多 2.6799999 這種數字。打包時我們先換單位:

ts
// 座標存整數公分,角度存百分之一弧度,縮放存千分之一
const cm = (m: number) => Math.round(m * 100);

const row = [item.type, cm(item.x), cm(item.z), Math.round(item.rotation * 100)];
// 家具有改過尺寸才多存兩個數字
if (item.scaleX !== undefined || item.scaleZ !== undefined) {
    row.push(Math.round((item.scaleX ?? 1) * 1000), Math.round((item.scaleZ ?? 1) * 1000));
}

欄位名稱也縮成一兩個字母:k 是格局種類,st 是風格,f 是家具,o 是外框,w 是牆,l 是房間名稱,fn 是地板。每件家具是一個陣列,不是物件,省掉重複的鍵名。

第二步:deflate-raw,再轉 base64url

瀏覽器內建 CompressionStream,不用裝套件。我們用 deflate-raw 格式壓縮,再把位元組轉成 base64url:A–Z、a–z、0–9、- 和 _,放進網址不用再跳脫。

ts
const deflate = async (text: string) => {
    const stream = new Blob([text]).stream().pipeThrough(new CompressionStream('deflate-raw'));
    return new Uint8Array(await new Response(stream).arrayBuffer());
};

export const encodeShareHash = async (data: SharedLayout) =>
    `v=1&s=${toBase64Url(await deflate(JSON.stringify(packShare(data))))}`;

這是 v=1 的寫法。v=2 在這裡多加了一步,後面會講。

我們拿正式站 7 個範本各產生一次分享連結,再把每一段的長度量出來。「直接 URL 編碼」是對同一份 JSON 做 encodeURIComponent,當作不壓縮的對照組:

範本JSON直接 URL 編碼JSON 轉 base64urldeflate-raw最後的網址
透天 1 樓639 B1,203 字元852 字元345 B511 字元
15 坪 1 房713 B1,325 字元951 字元380 B558 字元
20 坪 2 房868 B1,598 字元1,158 字元439 B637 字元
20 坪 3 房978 B1,802 字元1,304 字元470 B678 字元
25 坪 3 房1,043 B1,909 字元1,391 字元490 B705 字元
30 坪 3 房1,230 B2,234 字元1,640 字元537 B767 字元
40 坪 4 房1,389 B2,515 字元1,852 字元587 B834 字元
7 個範本的 v=1 分享連結長度(2026-10-16 正式站產生;B 是位元組)

三件事從表上看得出來:

1. 直接 URL 編碼最長。房間名稱是中文,每個中文字是 3 個位元組,編碼後變成 9 個字元。

2. deflate-raw 把 JSON 壓到原本的 42%~54%。7 個範本裡,JSON 越大,壓縮比越好。我們的推測是牆和家具的陣列長得很像,重複越多越好壓;這點沒有另外驗證。

3. base64url 會把位元組放大成 4/3 倍。20 坪 3 房的 470 位元組,變成 627 個字元,加上網址前段,總共 678 字元。

v=2 在壓縮後的位元組前面多放 4 個位元組的校驗碼。換成網址,每條多 5~6 個字元。

表上的數字是 Chromium 產生的。壓縮結果會因瀏覽器而不同。同一個 20 坪 3 房範本,JSON 都是 978 位元組;Chromium 145 壓成 470 位元組,WebKit 26.0 壓成 466 位元組。兩邊解開的內容一樣,但連結長度會差幾個字元。

網址多長算太長

HTTP 規格 RFC 9110 建議:傳送端和接收端至少都要支援 8000 個位元組長的 URI。這是「建議的最低支援」,不是上限。

我們把產生端的上限設在 8000 字元。超過就不產生連結,改請使用者用「匯出 JSON」傳檔案。7 個範本最長才 834 字元,離上限很遠;上限是留給使用者自己上傳、牆很多的大格局。

聊天 App 會不會截斷長網址,我們沒有實測。不過連結就算被截斷,也不會載入一半。我們把一條 v=2 連結截在 8 個位置,從只少最後 1 個字元,到 s 整段刪光。8 次都顯示「連結不完整或被改過」。這是在本機建置版、用 Playwright 內建的 WebKit 26.0 測的。

打開連結的一方:所有東西都當成不可信

分享連結是別人給的資料。任何人都能手改網址,所以解開時每一步都要檢查:

1. 版本號只接受 1 和 2。其他的就說「格式版本不同」。v=2 還要先通過校驗碼,見後文。

2. s 超過 16000 字元,不解壓,直接說「連結太長」。

3. 解壓時邊讀邊算大小,超過 256 KB 就停。這是擋「壓縮炸彈」:一小段資料解開後變成巨大檔案。

4. 陣列數量有上限(例如家具最多 400 件),座標限制在正負 1000 公尺內,家具種類只接受工具認得的 13 種。

5. 房間名稱只拿來畫在 canvas 上,不放進 HTML。即使如此,仍去掉控制字元、換行類字元和雙向控制字元,最多留 40 個字。雙向控制字元能讓畫出來的字順序和實際內容不同。

任何一項不合,整份都不要,不會只載入一半。產生連結的一方也會先自己解一次:解不回來的連結,不給出去。

我們在正式站實測了 5 種壞連結。當時正式站還是 v=1,所以第 2 列的 v=2 是不認得的版本。現在 v=2 是正式格式,同樣把 v=1 改成 v=2,會顯示「連結不完整或被改過」(本機建置版實測):

怎麼弄壞畫面顯示
砍掉後半段這個分享連結壞掉了或被改過,打不開。
v=1 改成 v=2這個分享連結的格式版本不同,這裡打不開。
第 21、22 個字元換成「!」這個分享連結壞掉了或被改過,打不開。
s 塞 16001 個字元這個分享連結太長,打不開。請對方改用「匯出 JSON」傳檔案。
改中間 1 個字元這個分享連結壞掉了或被改過,打不開。
5 種壞 v=1 連結在正式站的結果(2026-10-16,headless Chromium)

最後一列容易讓人誤會:「改 1 個字元一定會被抓到」。不是這樣。

改 1 個字元,有時候還是打得開

常見的壓縮格式有三種包裝。zlib 格式尾端有 Adler-32 校驗碼,gzip 有 CRC-32,deflate-raw 什麼都沒有。MDN 對 deflate-raw 的說明就是「沒有標頭和尾端校驗碼」。

沒有校驗碼,代表解壓的一方無法確認資料有沒有被改過。被改過的資料,可能剛好還是合法的 deflate、合法的 UTF-8、合法的 JSON,也通過所有範圍檢查。

我們實際算了一次。拿 20 坪 3 房的分享連結,s 有 627 個字元。每個位置各改一次,換成 base64url 字母表的下一個字元,共 627 條新連結:

結果條數
解壓失敗146
解出來不是合法 UTF-8232
不是合法 JSON169
JSON 合法,但沒通過範圍檢查32
通過,內容和原本一樣2
通過,內容和原本不同46
20 坪 3 房分享連結逐一改 1 個字元的結果(627 條;離線用 Python 照工具的檢查規則重算)

579 條被擋下,46 條(約 7%)會打開成另一個格局。這 46 條改到的東西:16 條只改到房間名稱,9 條只改到家具,5 條只改到牆,5 條只改到地板,3 條只改到風格名稱,8 條同時改到好幾項。

內容一樣的那 2 條,解壓後和原本完全相同。可能是改到了沒有用到的位元,例如最後一個字元的補位;我們沒有逐條確認。

離線重算的檢查是我們照程式移植的,不是正式站本身。所以我們挑了其中 5 條,到正式站實際打開。5 條都顯示「這是別人分享的格局」,沒有任何錯誤訊息。

3D 格局工具俯視畫面:20 坪 3 房,上方顯示「這是別人分享的格局」,餐桌上方原本的兩張椅子不見了
改了 1 個字元的連結,在正式站照常打開。餐桌上方的兩張椅子不見了:它們的位置從 2.73 公尺變成 82.73 公尺,被放到畫面外很遠的地方。

這張圖有兩個值得注意的地方。

第一,改 1 個字元,動到的是兩張椅子。兩張椅子原本的資料都有「273,314」這一段。deflate 會把重複出現的片段改寫成「往前第幾個位元組,複製幾個」。我們的推測是第二張椅子引用了第一張的這一段,所以一起被改。

第二,82.73 公尺仍在正負 1000 公尺的範圍內,所以檢查放行。另一條連結只把同樣兩張椅子挪了 3 公分,肉眼根本看不出來。

這代表什麼,不代表什麼

它不是安全漏洞。想改格局的人,直接在工具裡拖家具、再產生一條新連結就好,不用去改網址。範圍檢查要防的是程式出錯和資源耗盡。前面 5 種壞連結都擋住了;壓縮炸彈那一條,這次沒有實測。

它代表的是:v=1 的分享連結不保證「對方看到的,就是你傳出的」。如果連結在途中被截斷或抄錯一個字,大多數情況會顯示錯誤;但約 7% 的單字元錯誤,會安靜地變成另一個格局。

我們的修法:v=2 加上 CRC-32

新的連結是 v=2。壓縮後的位元組前面,多放 4 個位元組的 CRC-32 校驗碼。打開時先重算一次,對不上就顯示「連結不完整或被改過」,不載入,也不解壓。

我們選 CRC-32,是因為它對這種錯誤有保證。一個 base64url 字元只代表 6 個位元,CRC-32 一定抓得到 32 位元以內的連續錯誤,所以改 1 個字元一定會被擋。

還有一個小洞要補:base64url 最後一個字元可能有幾個位元沒用到,改了它,解出來的位元組完全一樣。前面「內容一樣」的 2 條,可能就是這種。v=2 要求連結是標準寫法,這種改法也擋。

單元測試把一條 v=2 連結的每個位置都換成另外 63 個字元,也試了刪掉一個字元、多插一個字元。全部回「連結不完整或被改過」。

已經傳出去的 v=1 連結還是打得開,照原本的規則檢查,沒有校驗碼。重新按一次「複製分享連結」,就會變成 v=2。

CRC-32 只擋意外,擋不了故意偽造:想改內容的人可以連校驗碼一起重算。要擋偽造得有伺服器端的金鑰,那就回到我們一開始不想做的事。

一個 Safari 相關的細節

壓縮是非同步的,要等一下才拿得到網址。但有些瀏覽器只允許在「點擊的同一個工作」裡寫入剪貼簿,等壓縮完再寫,可能已經被擋。

我們的做法是:瀏覽器有 ClipboardItem 時,點擊當下就把「還在壓縮的網址」當成 Promise 交給剪貼簿。剪貼簿也被擋時,就把網址顯示在畫面上,請使用者手動複製。程式註解寫的原因是 Safari。我們用 Playwright 內建的 WebKit 26.0 試過,按下後顯示「已複製分享連結」。WebKit 是 Safari 的引擎,但這不是 Safari 本身。在這個測試環境裡,等 300 毫秒再寫入剪貼簿也會成功,沒有重現「被擋」。所以在真的 Safari 和 iPhone 上,這個寫法是不是必要,我們沒有驗證。

這次實測的範圍

v=1 的實測全部在 2026-10-16 用 headless Chromium 對正式站 keeponfirst.com 操作。Safari、Firefox、手機瀏覽器沒有測。

v=2 的校驗是用單元測試驗的,正式站沒有重測。另外在本機建置版,用 Playwright 內建的 WebKit 26.0 測了複製和截斷,結果見前文。

改字元的實驗只做了一個範本,用的是 Chromium 產生的連結。每個位置只換成「下一個字元」一種改法。換別的字元或別的瀏覽器,比例可能不同。

想自己試,可以打開任一個範本,按「複製分享連結」,把網址 s= 後面改掉一個字元再貼回網址列。現在會看到「連結不完整或被改過」。

打開 3D 格局工具7 個台灣格局範本:每個都能產生分享連結這個工具怎麼把平面圖變成 3D:Three.js 實作筆記用同一個工具試擺床:台規和 IKEA 長度差 12 公分

把資料塞進網址很省事,不用後端,也不用帳號。但網址沒有校驗碼時,「打得開」和「沒被改過」是兩件事,所以我們補上了 CRC-32。

參考資料