Skip to content

Commit 49f42de

Browse files
committed
fix(webapp): allow the org-avatar favicon endpoint in the img-src policy
1 parent 8f017ec commit 49f42de

3 files changed

Lines changed: 47 additions & 10 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 };

0 commit comments

Comments
 (0)