Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions packages/console/src/features/sandboxes/envd-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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。
*/
Expand All @@ -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}`,
Expand Down
48 changes: 48 additions & 0 deletions packages/server/src/e2b/compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions packages/server/src/e2b/signing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down