Skip to content

Commit f87fe46

Browse files
committed
keamanan: resume_token ber-HMAC (sig sha256, verifikasi timing-safe sebelum pakai), allowlist kunci state_ref di kunciDariRef, containment path store runtime; temuan minor verifier wave-2
1 parent 4eb5a9d commit f87fe46

4 files changed

Lines changed: 84 additions & 16 deletions

File tree

apps/web/lib/gudang.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
// empat pembaca di bawah ini dihapus dan diganti pemanggilan core lagi.
1212

1313
import { access, appendFile, mkdir, readFile, readdir } from "node:fs/promises";
14-
import { dirname, join } from "node:path";
14+
import { dirname, join, resolve, sep } from "node:path";
1515

1616
import { periksaBerkas, resolverGrounding } from "@varuna/core/grounding";
1717
import type { RuntimeStore } from "@varuna/core/store";
@@ -255,7 +255,16 @@ const dirRuntime = (): string =>
255255
* append-only JSONL. packages/core/src/store.ts sengaja menyerahkan
256256
* implementasinya ke jalur produk ini ("blobRuntimeStore: diisi di apps/web"). */
257257
export function gudangRuntime(): RuntimeStore {
258-
const path = (kunci: string) => join(/* turbopackIgnore: true */ dirRuntime(), kunci);
258+
// Containment: kunci apa pun (termasuk dari resume_token) tidak boleh
259+
// menembus keluar direktori runtime.
260+
const path = (kunci: string) => {
261+
const dasar = resolve(dirRuntime());
262+
const p = resolve(/* turbopackIgnore: true */ dasar, kunci);
263+
if (p !== dasar && !p.startsWith(dasar + sep)) {
264+
throw new Error(`kunci runtime di luar direktori: ${kunci}`);
265+
}
266+
return p;
267+
};
259268
return {
260269
async append(kunci, rekaman) {
261270
const p = path(kunci);

packages/agents/src/executor.ts

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
// Yang TIDAK dilakukan agen mana pun di berkas ini: menghitung status. PASHA
1313
// dipanggil executor (server) dari artefak yang dikutip replay, sesuai Bagian 4.
1414

15+
import { createHmac, timingSafeEqual } from "node:crypto";
16+
1517
import {
1618
ModelBehaviorError,
1719
RunState,
@@ -81,9 +83,33 @@ export const ResumeTokenSchema = z.object({
8183
ttl_s: z.literal(TTL_DETIK),
8284
run_id: z.string().regex(/^[a-z0-9]{6,32}$/),
8385
expires_at: z.iso.datetime({ offset: true }),
86+
sig: z.string().regex(/^[0-9a-f]{64}$/),
8487
});
8588
export type ResumeToken = z.infer<typeof ResumeTokenSchema>;
8689

90+
/** Token adalah bearer yang bolak-balik lewat klien; tanpa tanda tangan, field
91+
* mana pun (terutama state_ref) bisa diganti. HMAC atas JSON kanonis tujuh
92+
* field non-sig; rahasia dari env, fallback nilai dev yang sama konvensinya
93+
* dengan garam MMSI. */
94+
const rahasiaToken = (): string =>
95+
process.env.RESUME_TOKEN_SECRET ?? process.env.MMSI_HASH_SALT ?? "varuna-dev-salt-2026";
96+
97+
const tandaTanganToken = (t: Omit<ResumeToken, "sig">): string =>
98+
createHmac("sha256", rahasiaToken())
99+
.update(
100+
JSON.stringify(
101+
Object.fromEntries(Object.entries(t).sort(([a], [b]) => (a < b ? -1 : 1))),
102+
),
103+
)
104+
.digest("hex");
105+
106+
const sigCocok = (t: ResumeToken): boolean => {
107+
const { sig, ...tanpa } = t;
108+
const a = Buffer.from(tandaTanganToken(tanpa), "hex");
109+
const b = Buffer.from(sig, "hex");
110+
return a.length === b.length && timingSafeEqual(a, b);
111+
};
112+
87113
// ---------------------------------------------------------------------------
88114
// Peristiwa SSE (Bagian 3, event `agent_step`)
89115
// ---------------------------------------------------------------------------
@@ -456,17 +482,18 @@ async function jeda(
456482
});
457483

458484
const nama = namaTertunda(tertunda[0] as RunToolApprovalItem);
485+
const tanpaSig: Omit<ResumeToken, "sig"> = {
486+
inv_id: ktx.inv.inv_id,
487+
step_idx: berikut,
488+
state_ref: refState(kunci),
489+
seed: SEED,
490+
ttl_s: TTL_DETIK,
491+
run_id: ktx.run_id,
492+
expires_at: new Date(Date.parse(m.sekarang()) + TTL_DETIK * 1000).toISOString(),
493+
};
459494
return {
460495
selesai: false,
461-
resume_token: {
462-
inv_id: ktx.inv.inv_id,
463-
step_idx: berikut,
464-
state_ref: refState(kunci),
465-
seed: SEED,
466-
ttl_s: TTL_DETIK,
467-
run_id: ktx.run_id,
468-
expires_at: new Date(Date.parse(m.sekarang()) + TTL_DETIK * 1000).toISOString(),
469-
},
496+
resume_token: { ...tanpaSig, sig: tandaTanganToken(tanpaSig) },
470497
agen_berikut: nama === undefined ? null : idDariNamaAlat(nama),
471498
trace_ref: ktx.trace_ref,
472499
run_id: ktx.run_id,
@@ -515,6 +542,9 @@ export async function lanjutkanReplay(
515542
if (!token.success) {
516543
throw new GalatReplay("token", "resume_token tidak sah atau bukan milik protokol replay ini.");
517544
}
545+
if (!sigCocok(token.data)) {
546+
throw new GalatReplay("token", "tanda tangan resume_token tidak cocok; token diubah atau berasal dari server lain.");
547+
}
518548
if (Date.parse(token.data.expires_at) <= Date.parse(m.sekarang())) {
519549
throw new GalatReplay(
520550
"kedaluwarsa",

packages/agents/src/mesin.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,15 @@ export const kunciJejak = (inv_id: string, run_id: string): string =>
6969
* Vercel Blob atau filesystem dev. */
7070
export const refState = (kunci: string): string => `blob://${kunci}`;
7171

72-
export const kunciDariRef = (ref: string): string | null =>
73-
ref.startsWith("blob://") ? ref.slice("blob://".length) : null;
72+
/** Kunci datang dari resume_token (input tak tepercaya di route publik):
73+
* hanya segmen [a-z0-9._-] dipisah "/", tanpa "..", tanpa awalan "/". */
74+
const POLA_KUNCI = /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9][a-z0-9._-]*)*$/i;
75+
export const kunciDariRef = (ref: string): string | null => {
76+
if (!ref.startsWith("blob://")) return null;
77+
const kunci = ref.slice("blob://".length);
78+
if (!POLA_KUNCI.test(kunci) || kunci.split("/").includes("..")) return null;
79+
return kunci;
80+
};
7481

7582
// ---------------------------------------------------------------------------
7683
// Katalog bukti

packages/agents/test/executor.test.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
type HasilLangkah,
99
type PeristiwaAgen,
1010
} from "../src/executor";
11-
import { SEED, TTL_DETIK, kunciJejak, kunciKeluaran, kunciState } from "../src/mesin";
11+
import { SEED, TTL_DETIK, kunciDariRef, kunciJejak, kunciKeluaran, kunciState } from "../src/mesin";
1212
import {
1313
ART,
1414
INVESTIGASI_UJI,
@@ -215,9 +215,11 @@ describe("resume_token — penjaga batas kepercayaan", () => {
215215
};
216216

217217
it("menolak token kedaluwarsa (ttl 900 detik)", async () => {
218+
// Jam dimajukan, token utuh: tanda tangan tetap sah, yang gugur murni TTL.
218219
const { r, token } = await tokenSah();
219-
const lewat = { ...token, expires_at: "2026-08-09T05:00:00.000Z" };
220-
await expect(lanjutkanReplay(r.mesin, lewat, () => {})).rejects.toMatchObject({
220+
(r.mesin as { sekarang: () => string }).sekarang = () =>
221+
new Date(Date.parse(token.expires_at) + 1000).toISOString();
222+
await expect(lanjutkanReplay(r.mesin, token, () => {})).rejects.toMatchObject({
221223
sebab: "kedaluwarsa",
222224
});
223225
});
@@ -266,6 +268,26 @@ describe("determinisme seed 20260809", () => {
266268
expect([...posisi].sort((a, b) => a - b)).toEqual(posisi);
267269
});
268270

271+
it("menolak resume_token yang tanda tangannya dirusak", async () => {
272+
const r = siapkan(naskahStandar());
273+
const awal = await mulaiReplay(r.mesin, INV_ID, () => {});
274+
const rusak = {
275+
...(awal.resume_token as Record<string, unknown>),
276+
state_ref: "blob://runtime/lain/state.jsonl",
277+
};
278+
await expect(lanjutkanReplay(r.mesin, rusak, () => {})).rejects.toMatchObject({
279+
sebab: "token",
280+
});
281+
});
282+
283+
it("menolak state_ref traversal path meski tanda tangan dibuat ulang", async () => {
284+
// kunciDariRef adalah gerbangnya: ".." tidak pernah jadi kunci store.
285+
expect(kunciDariRef("blob://../../etc/passwd")).toBeNull();
286+
expect(kunciDariRef("blob:///etc/passwd")).toBeNull();
287+
expect(kunciDariRef("blob://runtime/inv/..%2F")).toBeNull();
288+
expect(kunciDariRef("blob://runtime/inv-x/state.jsonl")).toBe("runtime/inv-x/state.jsonl");
289+
});
290+
269291
it("menulis jejak dan state di bawah run_id yang sama", async () => {
270292
const r = siapkan(naskahStandar());
271293
const awal = await mulaiReplay(r.mesin, INV_ID, () => {});

0 commit comments

Comments
 (0)