Skip to content

Commit a6782c0

Browse files
committed
Merge remote-tracking branch 'origin/feat/dashboard-agent-flows' into feat/dashboard-agent-ui
2 parents a72b797 + 49f42de commit a6782c0

4 files changed

Lines changed: 84 additions & 16 deletions

File tree

apps/webapp/app/entry.server.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,12 @@ import { workerRegionRegistry } from "./v3/workerRegions.server";
4949
const ABORT_DELAY = 30000;
5050

5151
/**
52-
* Where a document may load images from. The agent's markdown renders no images at
53-
* all (`components/dashboard-agent/model-markdown.ts`); this is the backstop, so a
54-
* model-authored image that ever slips through still can't reach a remote host.
52+
* Where a document may load images from. The markdown renderer that strips images
53+
* ships in the stacked UI PR, so on this branch the policy is the only thing stopping
54+
* a model- or customer-authored image from reaching a remote host.
5555
*
56-
* Only exact origins: the GitHub avatar host we store avatar URLs for, plus
57-
* whatever `CSP_IMG_SRC_ALLOWLIST` adds (e.g. a self-hosted SSO avatar host).
56+
* The hosts we store avatar URLs for, plus whatever `CSP_IMG_SRC_ALLOWLIST` adds
57+
* (e.g. a self-hosted SSO avatar host).
5858
*/
5959
const IMG_SRC_DIRECTIVE = buildImgSrcDirective(
6060
singleton("CspImageOrigins", () => {

apps/webapp/app/utils/cspImageOrigins.test.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,26 @@
11
import { describe, expect, it } from "vitest";
2+
import { faviconUrl } from "./favicon";
23
import {
34
BASE_IMG_SRC_SOURCES,
45
buildImgSrcDirective,
56
parseCspImageOrigins,
67
withImgSrc,
78
} from "./cspImageOrigins";
89

10+
/** True if a source expression in the directive would match the given image URL. */
11+
function directivePermits(directive: string, imageUrl: string): boolean {
12+
const url = new URL(imageUrl);
13+
return directive
14+
.split(" ")
15+
.slice(1)
16+
.some((source) => {
17+
if (!source.startsWith("http")) return false;
18+
const parsed = new URL(source);
19+
if (parsed.protocol !== url.protocol || parsed.host !== url.host) return false;
20+
return parsed.pathname === "/" || parsed.pathname === url.pathname;
21+
});
22+
}
23+
924
describe("parseCspImageOrigins", () => {
1025
it("accepts exact https origins, with or without a port", () => {
1126
const { origins, rejected } = parseCspImageOrigins(
@@ -75,12 +90,28 @@ describe("parseCspImageOrigins", () => {
7590
});
7691

7792
describe("buildImgSrcDirective", () => {
78-
it("is self, data, blob and the SSO avatar hosts by default", () => {
93+
it("is self, data, blob, the SSO avatar hosts and the favicon endpoint by default", () => {
7994
expect(buildImgSrcDirective()).toBe(
80-
"img-src 'self' data: blob: https://avatars.githubusercontent.com https://lh3.googleusercontent.com"
95+
"img-src 'self' data: blob: https://avatars.githubusercontent.com https://lh3.googleusercontent.com https://www.google.com/s2/favicons"
8196
);
8297
});
8398

99+
it("permits the org avatar URL the app actually stores", () => {
100+
expect(directivePermits(buildImgSrcDirective(), faviconUrl("example.com"))).toBe(true);
101+
});
102+
103+
it("permits nothing else on the favicon host", () => {
104+
expect(directivePermits(buildImgSrcDirective(), "https://www.google.com/beacon.png")).toBe(
105+
false
106+
);
107+
});
108+
109+
it("permits both OAuth avatar hosts", () => {
110+
const directive = buildImgSrcDirective();
111+
expect(directivePermits(directive, "https://avatars.githubusercontent.com/u/1?v=4")).toBe(true);
112+
expect(directivePermits(directive, "https://lh3.googleusercontent.com/a/abc=s96-c")).toBe(true);
113+
});
114+
84115
it("has no wildcard host and no bare scheme host", () => {
85116
const directive = buildImgSrcDirective(parseCspImageOrigins("https://sso.example.com").origins);
86117
expect(directive).not.toContain("*");

apps/webapp/app/utils/cspImageOrigins.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
/**
22
* The document `img-src` allowlist. Remote images are a beacon channel: rendering
3-
* one is the outbound request, no click needed. So the list is exact origins only —
4-
* no wildcard host, no bare scheme, nothing with a path.
3+
* one is the outbound request, no click needed. So no wildcard host and no bare
4+
* scheme. Operator-supplied entries are exact origins; a base source may pin a path
5+
* to narrow the host further.
56
*/
67

7-
/** Always allowed: own origin, inline data, object URLs, and the SSO avatar hosts. */
8+
/**
9+
* Always allowed: own origin, inline data, object URLs, the SSO avatar hosts, and the
10+
* favicon endpoint org avatars are stored as (see `utils/favicon.ts`). The path pins
11+
* that one endpoint — CSP matches the path and ignores the query string.
12+
*/
813
export const BASE_IMG_SRC_SOURCES = [
914
"'self'",
1015
"data:",
1116
"blob:",
1217
"https://avatars.githubusercontent.com",
1318
"https://lh3.googleusercontent.com",
19+
"https://www.google.com/s2/favicons",
1420
] as const;
1521

1622
export type RejectedOrigin = { value: string; reason: string };

apps/webapp/test/contextlessPatRoutes.test.ts

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,18 +53,40 @@ import { loader as projectsLoader } from "~/routes/api.v1.projects";
5353

5454
const USER_ID = "usr_1";
5555

56-
async function createOrg(cap: string[]): Promise<{ status: number; body: any }> {
56+
// Counts `can()` invocations without changing what the ability answers.
57+
function countingAbility(ability: any): { ability: any; canCalls: () => number } {
58+
let canCalls = 0;
59+
const wrapped = new Proxy(ability, {
60+
get(target, prop, receiver) {
61+
const value = Reflect.get(target, prop, receiver);
62+
if (typeof value !== "function") {
63+
return value;
64+
}
65+
if (prop === "can") {
66+
return (...args: any[]) => {
67+
canCalls++;
68+
return value.apply(target, args);
69+
};
70+
}
71+
return value.bind(target);
72+
},
73+
});
74+
return { ability: wrapped, canCalls: () => canCalls };
75+
}
76+
77+
async function createOrg(cap: string[]): Promise<{ status: number; body: any; canCalls: number }> {
5778
const token = await signUserActorToken(SESSION_SECRET, {
5879
userId: USER_ID,
5980
client: "personal-access-token",
6081
cap,
6182
});
83+
const counted = countingAbility(buildJwtAbility(cap));
6284
mocks.authenticateUserActor.mockImplementation(async () => ({
6385
ok: true,
6486
userId: USER_ID,
6587
claims: { userId: USER_ID, client: "personal-access-token", cap },
6688
subject: { type: "userActor", userId: USER_ID, organizationId: "org_1" },
67-
ability: buildJwtAbility(cap),
89+
ability: counted.ability,
6890
}));
6991

7092
const response = await action({
@@ -76,19 +98,26 @@ async function createOrg(cap: string[]): Promise<{ status: number; body: any }>
7698
params: {},
7799
context: {},
78100
} as any);
79-
return { status: response.status, body: await response.json() };
101+
return { status: response.status, body: await response.json(), canCalls: counted.canCalls() };
80102
}
81103

82104
// An ordinary PAT, paired with an ability that denies everything. Nothing on this route may
83105
// consult it — the route has no org to scope a gate to, and on cloud the plugin returns a
84106
// deny-shaped ability when there is no org context.
85-
async function createOrgWithPat(): Promise<{ status: number; body: any }> {
107+
async function createOrgWithPat(): Promise<{ status: number; body: any; canCalls: number }> {
108+
let canCalls = 0;
86109
mocks.authenticatePat.mockImplementation(async () => ({
87110
ok: true,
88111
userId: USER_ID,
89112
tokenId: "pat_1",
90113
lastAccessedAt: new Date(),
91-
ability: { can: () => false, canSuper: () => false },
114+
ability: {
115+
can: () => {
116+
canCalls++;
117+
return false;
118+
},
119+
canSuper: () => false,
120+
},
92121
}));
93122

94123
const response = await action({
@@ -100,7 +129,7 @@ async function createOrgWithPat(): Promise<{ status: number; body: any }> {
100129
params: {},
101130
context: {},
102131
} as any);
103-
return { status: response.status, body: await response.json() };
132+
return { status: response.status, body: await response.json(), canCalls };
104133
}
105134

106135
const AGENT_ENVIRONMENT_ID = "env_dev";
@@ -177,6 +206,7 @@ describe("creating an organization over the API", () => {
177206

178207
expect(result.status).toBe(201);
179208
expect(result.body.slug).toBe("new-org");
209+
expect(result.canCalls).toBe(0);
180210
});
181211

182212
// The env gate runs before the capability gate, so an install with the API disabled tells
@@ -189,6 +219,7 @@ describe("creating an organization over the API", () => {
189219
const result = await createOrg(["read:all"]);
190220

191221
expect(result.status).toBe(404);
222+
expect(result.canCalls).toBe(0);
192223
expect(mocks.createOrganization).not.toHaveBeenCalled();
193224
} finally {
194225
mocks.env.ORG_CREATION_API_ENABLED = "1";

0 commit comments

Comments
 (0)