diff --git a/packages/console/src/features/sandboxes/envd-client.ts b/packages/console/src/features/sandboxes/envd-client.ts index e8dd2f85..172be713 100644 --- a/packages/console/src/features/sandboxes/envd-client.ts +++ b/packages/console/src/features/sandboxes/envd-client.ts @@ -226,10 +226,25 @@ export const killProcess = ( // ---- 签名直链(daemon 根 /files,给第三方抓取用)--------------------------- +/** + * 逐字节拼成二进制字符串给 btoa:等价于 String.fromCharCode(...bytes), + * 但不把整个数组摊进实参 — 那个写法有参数个数上限,换个地方复用(比如 + * 更长的 buffer)就会爆栈,这里没有理由留这个坑。 + */ +function bytesToBinary(bytes: Uint8Array): string { + let out = ''; + for (const byte of bytes) out += String.fromCharCode(byte); + return out; +} + /** * 15 分钟:Microsoft 查看器加载时抓取、大文档翻页可能补抓,太短会断 * 查看器的懒加载;再长只是白白加宽泄露窗 — 每次点击/刷新都重铸, * 过期是一次点击的事。 + * + * 代价要说明白:这条 URL 是一张 15 分钟内不可撤销的凭证 — 销毁沙箱或 + * 轮换 API token 都不会让它失效,只有时钟会。所以它只适合"给第三方 + * 抓一次文件"这种一次性场景,不该往外扩散。 */ export const SIGNED_URL_TTL_SECONDS = 15 * 60; @@ -245,6 +260,12 @@ export const SIGNED_URL_TTL_SECONDS = 15 * 60; * `username=` 是 401,带 `username=user` 则材料对不上,两头只能"缺席" * (e2e/console.test 反向钉着这一条)。 * + * 这个格式不是本仓库挑的,是 E2B 的线上格式:官方 SDK 的 getSignature + * (packages/js-sdk/src/sandbox/signature.ts)算的就是 + * sha256("path:operation:user:envdAccessToken[:exp]"),真 envd 的验证 + * 也认它 —— 所以这里不能"顺手换成 HMAC":SDK 铸出来的 URL 会当场失效。 + * 两端的钉法见 server/e2b/signing.ts 顶注与 e2e/console.test。 + * * crypto.subtle 只在安全上下文存在(明文 HTTP 的 IP 访问没有)— * 调用方先闸 window.isSecureContext。 */ @@ -258,10 +279,7 @@ export async function signedDownloadUrl( 'SHA-256', new TextEncoder().encode(material), ); - const b64 = btoa(String.fromCharCode(...new Uint8Array(digest))).replace( - /=+$/, - '', - ); + const b64 = btoa(bytesToBinary(new Uint8Array(digest))).replace(/=+$/, ''); const query = new URLSearchParams({ path, signature: `v1_${b64}`, diff --git a/packages/server/src/e2b/compat.test.ts b/packages/server/src/e2b/compat.test.ts index e40d8ed3..a7358081 100644 --- a/packages/server/src/e2b/compat.test.ts +++ b/packages/server/src/e2b/compat.test.ts @@ -2146,6 +2146,54 @@ describe('signed file URLs at the daemon root', () => { expect(back.body).toBe('octets through the signed door\n'); }); + it('a signature over a reordered or extended material is nobody: the field order is the contract', async () => { + const t = testApp(); + const { envdAccessToken } = await createSandbox(t); + const exp = Math.floor(Date.now() / 1000) + 60; + const digest = (parts: string[]) => + `v1_${createHash('sha256') + .update(parts.join(':'), 'utf8') + .digest('base64') + .replace(/=+$/, '')}`; + + // Same components, the token moved out of its slot: the digest changes + // and nothing else in the query would have noticed. + const reordered = digest(['x', 'read', '', String(exp), envdAccessToken]); + // Same components plus one: an appended segment cannot ride the tail, + // the material is rebuilt from parsed params, not from the wire string. + const extended = digest([ + 'x', + 'read', + '', + envdAccessToken, + String(exp), + 'extra', + ]); + + for (const signature of [reordered, extended]) { + const res = await t.app.inject({ + method: 'GET', + url: `/files?path=x&signature=${encodeURIComponent(signature)}&signature_expiration=${exp}`, + }); + expect(res.statusCode).toBe(401); + expect(res.json().message).toBe('invalid signature'); + } + + // The canonical order still opens the door: what is pinned is the + // order, not an extra check bolted beside it. + const good = sdkSignature({ + path: 'x', + operation: 'read', + envdAccessToken, + expiration: exp, + }); + const accepted = await t.app.inject({ + method: 'GET', + url: `/files?path=x&signature=${encodeURIComponent(good)}&signature_expiration=${exp}`, + }); + expect(accepted.statusCode).not.toBe(401); + }); + it("refuses in real envd's order and words", async () => { const t = testApp(); const { envdAccessToken } = await createSandbox(t); diff --git a/packages/server/src/e2b/signing.ts b/packages/server/src/e2b/signing.ts index 305cf9a1..6360167c 100644 --- a/packages/server/src/e2b/signing.ts +++ b/packages/server/src/e2b/signing.ts @@ -16,6 +16,18 @@ import { e2bView } from './view'; * envd >= 0.4.0, which we report); `expiration` is an absolute unix-seconds * timestamp, present in the material exactly when the URL carries * `signature_expiration`. The token is the sandbox's envd access token. + * + * The shape is inherited, not chosen here: it is the string the official + * SDK hashes before the URL ever reaches us (js-sdk/src/sandbox/ + * signature.ts), and real envd verifies the same digest. Reading it as + * sha256(secret || data) and "fixing" it to HMAC would reject every + * SDK-minted URL, so the construction stays. Length extension has no graft + * point in it either: the material is rebuilt from parsed params in this + * order, the token sits fourth of five, and the only possible trailing + * component parses as a number — an appended suffix cannot survive that + * reading. The order is the contract, and both ends pin it: app.test.ts + * rewrites the SDK's formula, e2e/console.test mints it, and compat.test.ts + * refuses a reordered or extended material. */ export type SigningOperation = 'read' | 'write';