Skip to content

Commit eb4f824

Browse files
committed
fix(webapp): harden model-facing sanitizers, claims resolution and soft-delete idempotency (review)
1 parent dec52bd commit eb4f824

15 files changed

Lines changed: 198 additions & 41 deletions

.changeset/chat-stream-mid-turn-reconnect.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
"trigger.dev": patch
55
---
66

7-
Chat in the browser now reconnects when the connection drops mid-turn, instead of leaving the reply stuck as if it were still generating. Reports can be fetched as structured data with the `json` format, and the shortest report period is now one minute (`30m`, `1h`, `7d`). The `mint-token` command's help is clearer too: a token minted without `--cap` is read-only, and `--ttl` shows the correct maximum lifetime of 7 days.
7+
Chat in the browser now reconnects when the connection drops mid-turn, instead of leaving the reply stuck as if it were still generating. Reports can be fetched as structured data with the `json` format, and the shortest report period is now one minute (`1m`, `30m`, `1h`, `7d`). The `mint-token` command's help is clearer too: a token minted without `--cap` is read-only, and `--ttl` shows the correct maximum lifetime of 7 days.

apps/webapp/app/components/code/StreamdownRenderer.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ describe("restrictModelUrls (image src)", () => {
2020
expect(restrictModelUrls("//evil.tld/pixel.gif", "src", img)).toBeUndefined();
2121
});
2222

23+
it("drops a backslash-authority image, which the browser reads as protocol-relative", () => {
24+
expect(restrictModelUrls("\\\\evil.example/pixel.gif", "src", img)).toBeUndefined();
25+
expect(restrictModelUrls("/\\evil.example/pixel.gif", "src", img)).toBeUndefined();
26+
});
27+
28+
it("drops an image hidden behind a leading C0 control, which the URL parser discards", () => {
29+
expect(restrictModelUrls("\u0001//evil.tld/p.gif", "src", img)).toBeUndefined();
30+
expect(restrictModelUrls("\u0000https://evil.tld/p.gif", "src", img)).toBeUndefined();
31+
});
32+
2333
it("keeps inline and same-origin images", () => {
2434
expect(restrictModelUrls("data:image/png;base64,AAAA", "src", img)).toBe(
2535
"data:image/png;base64,AAAA"

apps/webapp/app/components/code/StreamdownRenderer.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,25 @@ const SAFE_LINK_SCHEMES = new Set(["http:", "https:", "mailto:"]);
1212
export const restrictModelUrls: UrlTransform = (url, key, node) => {
1313
const value = url.trim();
1414
const isImage = node.tagName === "img" || key === "src" || key === "srcset";
15+
// What the browser will actually resolve, which is not what `trim()` leaves: the URL parser
16+
// drops C0 controls (`trim()` keeps them) and reads `\` as `/` for special schemes, so
17+
// a leading-control `//evil.tld` and `\\evil.tld` both name a remote host. Classify on this; return the
18+
// original `url` untouched whenever it is allowed.
19+
const normalized = value
20+
.replace(/[\u0000-\u001f]/g, "")
21+
.replace(/^[/\\]+/, (run) => "/".repeat(run.length));
1522

1623
if (isImage) {
1724
// Inline images carry their own bytes; a relative path resolves to our own origin.
18-
if (/^data:/i.test(value) || /^blob:/i.test(value)) return url;
25+
if (/^data:/i.test(normalized) || /^blob:/i.test(normalized)) return url;
1926
// Absolute or protocol-relative means a remote host — strip it so nothing is fetched.
20-
if (/^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith("//")) return undefined;
27+
if (/^[a-z][a-z0-9+.-]*:/i.test(normalized) || normalized.startsWith("//")) return undefined;
2128
return url;
2229
}
2330

2431
// Links: relative and protocol-relative are fine; otherwise require a safe scheme.
25-
if (value.startsWith("//")) return url;
26-
const schemeMatch = /^([a-z][a-z0-9+.-]*):/i.exec(value);
32+
if (normalized.startsWith("//")) return url;
33+
const schemeMatch = /^([a-z][a-z0-9+.-]*):/i.exec(normalized);
2734
if (!schemeMatch) return url;
2835
return SAFE_LINK_SCHEMES.has(`${schemeMatch[1].toLowerCase()}:`) ? url : undefined;
2936
};

apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.render.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,13 @@ function markup(parts: unknown[]) {
6464
);
6565
}
6666

67+
/** Every needle must be present: a missing one is -1, and -1 comparisons pass vacuously. */
6768
function order(html: string, ...needles: string[]) {
68-
return needles.map((needle) => html.indexOf(needle));
69+
return needles.map((needle) => {
70+
const at = html.indexOf(needle);
71+
expect(at, `missing "${needle}"`).toBeGreaterThan(-1);
72+
return at;
73+
});
6974
}
7075

7176
describe("action rows render at the end of the turn", () => {

apps/webapp/app/components/dashboard-agent/model-markdown.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,29 @@ describe("stripModelImages", () => {
3131
expect(html).not.toContain("attacker.example");
3232
});
3333

34+
it("strips a nested tag, which one pass would splice back into a whole one", () => {
35+
expect(stripModelImages(`<im<img src="${BEACON}">g src="${BEACON}">`)).not.toContain(
36+
"attacker.example"
37+
);
38+
expect(stripModelImages("<scr<script>ipt>")).toBe("");
39+
});
40+
41+
it("bails out safely on nesting too deep to unwrap, instead of looping on it", () => {
42+
// Each layer needs one more pass, so this is the shape that makes the strip quadratic.
43+
let nested = `<script src="${BEACON}">`;
44+
for (let i = 0; i < 20_000; i++) nested = `<scr${nested}ipt>`;
45+
46+
const started = performance.now();
47+
const stripped = stripModelImages(nested);
48+
const elapsed = performance.now() - started;
49+
50+
// No bracket survives, so nothing can parse as an element — the URL is left as inert prose.
51+
expect(stripped).not.toContain("<script");
52+
expect(stripped).not.toContain("<");
53+
expect(render(stripped)).not.toMatch(/<script\b/i);
54+
expect(elapsed).toBeLessThan(1_000);
55+
});
56+
3457
it("strips an image hidden inside a code fence", () => {
3558
const stripped = stripModelImages(`\`\`\`\n![](${BEACON})\n\`\`\``);
3659
expect(stripped).not.toContain("attacker.example");

apps/webapp/app/components/dashboard-agent/model-markdown.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,33 @@ function plainAlt(alt: string): string {
1313
return alt.replace(/[![\]<>`]/g, "").trim();
1414
}
1515

16+
/**
17+
* One pass isn't enough: removing a match can splice the surrounding text into a fresh one
18+
* (`<scr<script>ipt>`), so repeat until nothing changes. Nesting deep enough to need more than
19+
* `MAX_PASSES` is adversarial, not real model output, and looping it out is quadratic on the
20+
* render thread — so past the bound we stop and blunt every character the strips key on. The
21+
* result is over-stripped, never half-stripped.
22+
*/
23+
const MAX_PASSES = 25;
24+
const STRIP_CHARS = /[![\]<>]/g;
25+
26+
function replaceUntilStable(
27+
text: string,
28+
pattern: RegExp,
29+
replacer: (whole: string, ...groups: string[]) => string
30+
): string {
31+
let current = text;
32+
for (let pass = 0; pass < MAX_PASSES; pass++) {
33+
const next = current.replace(pattern, replacer);
34+
if (next === current) return current;
35+
current = next;
36+
}
37+
return current.replace(STRIP_CHARS, "");
38+
}
39+
1640
// Strips inside code fences too: a fence-aware pass is bypassable with a half-fence.
1741
export function stripModelImages(text: string): string {
18-
return text
19-
.replace(MARKDOWN_IMAGE, (_whole, alt: string) => plainAlt(alt))
20-
.replace(MARKDOWN_SHORTCUT_IMAGE, (_whole, alt: string) => plainAlt(alt))
21-
.replace(FETCHING_TAG, "");
42+
let out = replaceUntilStable(text, MARKDOWN_IMAGE, (_whole, alt: string) => plainAlt(alt));
43+
out = replaceUntilStable(out, MARKDOWN_SHORTCUT_IMAGE, (_whole, alt: string) => plainAlt(alt));
44+
return replaceUntilStable(out, FETCHING_TAG, () => "");
2245
}

apps/webapp/app/services/dashboardAgentBodyCap.server.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@ const INGRESS_SLACK_BYTES = 8 * 1024;
1717
export const DASHBOARD_AGENT_MAX_INGRESS_BYTES = MAX_MESSAGE_BODY_BYTES + INGRESS_SLACK_BYTES;
1818

1919
// The agent's own routes only: the `/api/v1/dashboard-agent/…` endpoints and the
20-
// `/…/env/<env>/dashboard-agent…` chat resources. Anchored so a task named
21-
// `dashboard-agent` (`/api/v1/tasks/dashboard-agent/trigger`) is not capped.
22-
const AGENT_PATH = /^\/api\/v1\/dashboard-agent(\/|$)|\/env\/[^/]+\/dashboard-agent(\/|$)/;
20+
// `/resources/orgs/…/env/<env>/dashboard-agent…` chat resources. Both alternatives are
21+
// anchored, so neither a task named `dashboard-agent`
22+
// (`/api/v1/tasks/dashboard-agent/trigger`) nor a lookalike mid-path segment is capped.
23+
const AGENT_PATH =
24+
/^(?:\/api\/v1\/dashboard-agent|\/resources\/orgs\/[^/]+\/projects\/[^/]+\/env\/[^/]+\/dashboard-agent)(\/|$)/;
2325

2426
/** Methods that can carry one. GET and HEAD cannot, and streaming them would be wasted work. */
2527
const BODY_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);

apps/webapp/app/services/personalAccessToken.server.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -311,18 +311,18 @@ export async function assertSourcePatActive(claims: UserActorClaims): Promise<bo
311311
* supplied by the RBAC plugin (the apiBuilder path). We re-verify the bearer locally so the
312312
* recheck reads the token's OWN `pat`, not the plugin's: a plugin image predating the `pat`
313313
* claim would deliver pat-less claims and silently no-op revocation here. Returns the claims
314-
* to act on, or undefined to deny. The direct verify sites (jwt exchange, the UAT preamble)
315-
* don't go through a plugin and call `assertSourcePatActive` on their own verified claims.
314+
* to act on, or undefined to deny. Locally-verified claims win whenever verification succeeds
315+
* — a plugin predating a claim would otherwise narrow the set the route builder scopes on —
316+
* and the plugin's claims are only the fallback.
316317
*/
317318
export async function resolveAndRecheckUserActorClaims(
318319
claims: UserActorClaims | undefined,
319320
bearer: string
320321
): Promise<UserActorClaims | undefined> {
321322
const verified = await verifyUserActorToken(env.SESSION_SECRET, bearer);
322-
const resolved = claims ?? verified;
323+
const resolved = verified ?? claims;
323324
if (!resolved) return undefined;
324-
// Recheck against the locally-verified claims when available, so `pat` is authoritative.
325-
return (await assertSourcePatActive(verified ?? resolved)) ? resolved : undefined;
325+
return (await assertSourcePatActive(resolved)) ? resolved : undefined;
326326
}
327327

328328
/**

apps/webapp/test/dashboardAgentBodyCap.test.ts

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,10 @@ describe("the dashboard agent's ingress cap", () => {
7272

7373
// A client still uploading may see the reset rather than read the 413; either way the
7474
// request is over long before the body is.
75-
const response = await postChunked(`${url}/env/dev/dashboard-agent/in/append`, oversized).catch(
76-
() => undefined
77-
);
75+
const response = await postChunked(
76+
`${url}/resources/orgs/acme/projects/site/env/dev/dashboard-agent/in/append`,
77+
oversized
78+
).catch(() => undefined);
7879

7980
if (response) expect(response.status).toBe(413);
8081
expect(buffered()).toBeLessThan(oversized / 2);
@@ -84,7 +85,7 @@ describe("the dashboard agent's ingress cap", () => {
8485
const { url } = await listen();
8586

8687
const response = await postChunked(
87-
`${url}/env/dev/dashboard-agent/in/append`,
88+
`${url}/resources/orgs/acme/projects/site/env/dev/dashboard-agent/in/append`,
8889
DASHBOARD_AGENT_MAX_INGRESS_BYTES + 32 * 1024
8990
);
9091

@@ -94,11 +95,14 @@ describe("the dashboard agent's ingress cap", () => {
9495

9596
it("refuses a declared oversized body before reading anything", async () => {
9697
const { url, buffered } = await listen();
97-
const response = await fetch(`${url}/env/dev/dashboard-agent`, {
98-
method: "POST",
99-
headers: { "content-type": "text/plain" },
100-
body: "x".repeat(DASHBOARD_AGENT_MAX_INGRESS_BYTES + 1),
101-
});
98+
const response = await fetch(
99+
`${url}/resources/orgs/acme/projects/site/env/dev/dashboard-agent`,
100+
{
101+
method: "POST",
102+
headers: { "content-type": "text/plain" },
103+
body: "x".repeat(DASHBOARD_AGENT_MAX_INGRESS_BYTES + 1),
104+
}
105+
);
102106

103107
expect(response.status).toBe(413);
104108
expect(buffered()).toBe(0);
@@ -108,7 +112,10 @@ describe("the dashboard agent's ingress cap", () => {
108112
const { url } = await listen();
109113
const size = 32 * 1024;
110114

111-
const response = await postChunked(`${url}/env/dev/dashboard-agent`, size);
115+
const response = await postChunked(
116+
`${url}/resources/orgs/acme/projects/site/env/dev/dashboard-agent`,
117+
size
118+
);
112119

113120
expect(response.status).toBe(200);
114121
expect(await response.json()).toEqual({ bytes: size });
@@ -149,6 +156,19 @@ describe("the dashboard agent's ingress cap", () => {
149156
expect(buffered()).toBe(size);
150157
});
151158

159+
it("does not cap a lookalike that only carries the chat segment mid-path", async () => {
160+
const { url, buffered } = await listen();
161+
const size = DASHBOARD_AGENT_MAX_INGRESS_BYTES + 1024;
162+
163+
const response = await fetch(`${url}/api/v1/runs/env/dev/dashboard-agent`, {
164+
method: "POST",
165+
body: "x".repeat(size),
166+
});
167+
168+
expect(response.status).toBe(200);
169+
expect(buffered()).toBe(size);
170+
});
171+
152172
it("does not cap a task whose id is literally dashboard-agent", async () => {
153173
const { url, buffered } = await listen();
154174
const size = DASHBOARD_AGENT_MAX_INGRESS_BYTES + 1024;

apps/webapp/test/dashboardAgentQueriesTenantIsolation.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,29 @@ describe("softDeleteChat tenant isolation", () => {
7272
},
7373
30_000
7474
);
75+
76+
postgresTest(
77+
"deleting twice is a no-op: the retention cutoff doesn't move",
78+
async ({ prisma, postgresContainer }) => {
79+
const db = await boot(prisma, postgresContainer.getConnectionUri());
80+
const deletedAt = async () =>
81+
(
82+
await prisma.$queryRawUnsafe<{ deleted_at: Date | null }[]>(
83+
`select deleted_at from trigger_dashboard_agent.chats where id = 'chat_1'`
84+
)
85+
)[0]?.deleted_at;
86+
87+
await createChat(db, { id: "chat_1", organizationId: ORG, userId: USER });
88+
const params = { chatId: "chat_1", userId: USER, organizationId: ORG };
89+
90+
expect((await softDeleteChat(db, params)).deleted).toBe(true);
91+
const first = await deletedAt();
92+
expect(first).toBeInstanceOf(Date);
93+
94+
// A retry finds nothing left to delete, so the stamp stands.
95+
expect((await softDeleteChat(db, params)).deleted).toBe(false);
96+
expect(await deletedAt()).toEqual(first);
97+
},
98+
30_000
99+
);
75100
});

0 commit comments

Comments
 (0)