Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 0ebcce2

Browse files
committed
fix(agents): validate the agent slug before building the ingress URL
The agent slug becomes a subdomain label in the ingress URL (`<slug>.agents.<region>.posthog.com`), but it arrived from an `?agent=` approval deep link and was interpolated without any validation, so a slug that isn't a plain label could change where the resulting request goes. Validate it as a single DNS label in two places: the deep-link handler drops a malformed slug so the link falls back to the fleet Approvals inbox, and `agentIngressBaseUrl` returns null rather than interpolating one, which keeps callers on their existing "no ingress" path.
1 parent 271fe9b commit 0ebcce2

7 files changed

Lines changed: 96 additions & 11 deletions

File tree

packages/core/src/links/approval-link.test.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -102,20 +102,32 @@ describe("ApprovalLinkService", () => {
102102
expect(listener).toHaveBeenCalledWith(expected);
103103
});
104104

105-
it("carries the agent slug from the ?agent= query string", () => {
105+
it.each<{ name: string; search: string; agent: string | null }>([
106+
{
107+
name: "carries the agent slug from the ?agent= query string",
108+
search: "agent=my-agent",
109+
agent: "my-agent",
110+
},
111+
{
112+
name: "drops a slug that would smuggle another host into the ingress URL",
113+
search: "agent=evil.com%2F",
114+
agent: null,
115+
},
116+
{
117+
name: "drops a slug that is not a single DNS label",
118+
search: "agent=foo%2F..%2Fbar",
119+
agent: null,
120+
},
121+
])("$name", ({ search, agent }) => {
106122
const listener = vi.fn();
107123
service.on(ApprovalLinkEvent.OpenApproval, listener);
108124

109-
const result = deepLinkService.trigger(
110-
"approval",
111-
"ar_abc123",
112-
"agent=my-agent",
113-
);
125+
const result = deepLinkService.trigger("approval", "ar_abc123", search);
114126

115127
expect(result).toBe(true);
116128
expect(listener).toHaveBeenCalledWith({
117129
requestId: "ar_abc123",
118-
agent: "my-agent",
130+
agent,
119131
});
120132
});
121133

packages/core/src/links/approval-link.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
type IMainWindow,
88
MAIN_WINDOW_SERVICE,
99
} from "@posthog/platform/main-window";
10-
import { TypedEventEmitter } from "@posthog/shared";
10+
import { isValidAgentSlug, TypedEventEmitter } from "@posthog/shared";
1111
import { inject, injectable } from "inversify";
1212
import type { LinkLogger } from "./identifiers";
1313

@@ -72,9 +72,16 @@ export class ApprovalLinkService extends TypedEventEmitter<ApprovalLinkEvents> {
7272
return false;
7373
}
7474

75+
const agent = searchParams.get("agent");
76+
if (agent && !isValidAgentSlug(agent)) {
77+
this.log.warn(
78+
"Approval link carried a malformed agent slug; ignoring it",
79+
);
80+
}
81+
7582
const payload: ApprovalLinkPayload = {
7683
requestId,
77-
agent: searchParams.get("agent") || null,
84+
agent: isValidAgentSlug(agent) ? agent : null,
7885
};
7986

8087
const hasListeners = this.listenerCount(ApprovalLinkEvent.OpenApproval) > 0;
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, expect, it } from "vitest";
2+
import { isValidAgentSlug } from "./agent-slug";
3+
4+
describe("isValidAgentSlug", () => {
5+
it.each(["a", "my-agent", "agent123", "A1-b2", "x".repeat(63)])(
6+
"accepts the DNS label %s",
7+
(slug) => {
8+
expect(isValidAgentSlug(slug)).toBe(true);
9+
},
10+
);
11+
12+
it.each([
13+
"evil.com",
14+
"evil.com/",
15+
"evil.com#x",
16+
"evil.com?x",
17+
"evil.com:9999/",
18+
"evil.com\\x",
19+
"user@evil.com",
20+
"foo/../bar",
21+
"foo bar",
22+
"-lead",
23+
"trail-",
24+
"under_score",
25+
"x".repeat(64),
26+
"",
27+
null,
28+
undefined,
29+
])("rejects %s", (slug) => {
30+
expect(isValidAgentSlug(slug)).toBe(false);
31+
});
32+
});

packages/shared/src/agent-slug.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export const AGENT_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
2+
3+
export function isValidAgentSlug(
4+
slug: string | null | undefined,
5+
): slug is string {
6+
return !!slug && AGENT_SLUG_PATTERN.test(slug);
7+
}

packages/shared/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export type {
1919
AgentToolKind,
2020
} from "./agent-conversation";
2121
export * from "./agent-runtime";
22+
export { AGENT_SLUG_PATTERN, isValidAgentSlug } from "./agent-slug";
2223
export * from "./analytics-events";
2324
export { type ArchivedTask, archivedTaskSchema } from "./archive-domain";
2425
export { withTimeout } from "./async";
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { CloudRegion } from "@posthog/shared";
2+
import { describe, expect, it } from "vitest";
3+
import { agentIngressBaseUrl } from "./ingress";
4+
5+
describe("agentIngressBaseUrl", () => {
6+
it.each<{ region: CloudRegion; expected: string }>([
7+
{ region: "us", expected: "https://my-agent.agents.us.posthog.com" },
8+
{ region: "eu", expected: "https://my-agent.agents.eu.posthog.com" },
9+
{ region: "dev", expected: "http://localhost:3030/agents/my-agent" },
10+
])("builds the $region URL for a valid slug", ({ region, expected }) => {
11+
expect(agentIngressBaseUrl("my-agent", region)).toBe(expected);
12+
});
13+
14+
it.each(["evil.com/", "evil.com#x", "evil.com?x", "foo/../bar", ""])(
15+
"refuses to interpolate the slug %s",
16+
(slug) => {
17+
for (const region of ["us", "eu", "dev"] as const) {
18+
expect(agentIngressBaseUrl(slug, region)).toBeNull();
19+
}
20+
},
21+
);
22+
23+
it("returns null without a region", () => {
24+
expect(agentIngressBaseUrl("my-agent", null)).toBeNull();
25+
});
26+
});

packages/ui/src/features/agent-applications/utils/ingress.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { CloudRegion } from "@posthog/shared";
1+
import { type CloudRegion, isValidAgentSlug } from "@posthog/shared";
22

33
/**
44
* Resolve the agent-ingress base URL for live (streaming) calls, derived per
@@ -41,7 +41,7 @@ export function agentIngressBaseUrl(
4141
slug: string,
4242
region: CloudRegion | null,
4343
): string | null {
44-
if (!slug || !region) return null;
44+
if (!isValidAgentSlug(slug) || !region) return null;
4545
switch (region) {
4646
case "us":
4747
return `https://${slug}.agents.us.posthog.com`;

0 commit comments

Comments
 (0)