fix(webapp): org avatars blocked by img-src CSP and avatar overflow on failed load (#4600)

## What & why

Org avatars disappeared from the sidebar, replaced by alt text spilling
across it. Two bugs stacked: the document img-src CSP pins the Google
favicon endpoint org avatars are stored as, but Google 302-redirects it
to `tN.gstatic.com` and CSP re-checks the redirect target, so the avatar
is refused. Changelog images served from `trigger.dev` in the agent chat
were also missing from the allowlist. And `Avatar.tsx` had no clipping
and no error fallback, so a refused image degraded into overflowing alt
text.

## What's inside

**CSP allowlist** — `app/utils/cspImageOrigins.ts`: the base sources
gain the four gstatic shards `t0`–`t3.gstatic.com`, path-pinned to
`/faviconV2`, plus `https://trigger.dev/changelog/` as a path prefix. No
wildcards — the no-wildcard beacon policy stands. The shard hosts are
Google-operated with no public write path, so the enumeration is as
narrow as the existing `s2/favicons` entry; if Google ever adds a `t4`,
the failure mode is one broken avatar, not a broken page.

**Avatar fallback** — `app/components/primitives/Avatar.tsx`: the image
box clips, and a failed load falls back to the globe icon. That covers
failures before hydration too — `onError` never replays for a node that
already failed, so a ref checks `complete && naturalWidth === 0` at
attach time. The error state resets when the URL changes
(`key={avatar.url}`).

**Radio card theming** — `app/components/primitives/RadioButton.tsx`: in
the dark themes the checked radio card rendered darker than the
unchecked ones. Unchecked cards now sit on `background-bright`
(near-black in dark, unchanged white in light) and the checked card uses
the `surface-control` tokens, so selection reads black → grey in dark
themes; light theme keeps its current look. The API keys route keeps its
indigo checked-hover via an explicit override.

## Testing

The CSP test helper now implements CSP's real path-matching rule
(trailing slash = prefix, otherwise exact, query ignored) and asserts
the pins hold: the gstatic redirect target passes, `beacon.png` on
gstatic, a `t9` shard, and non-changelog `trigger.dev` paths stay
blocked. 39 tests green plus webapp typecheck. Verified against a
running webapp that the served directive contains the new sources.
This commit is contained in:
Katia Bulatova
2026-08-13 15:18:05 +02:00
committed by GitHub
parent 0b52af94fa
commit d1ac3d597d
7 changed files with 90 additions and 15 deletions
@@ -9,6 +9,7 @@ import {
StarIcon,
} from "@heroicons/react/20/solid";
import type { Prisma } from "@trigger.dev/database";
import { useCallback, useState } from "react";
import { z } from "zod";
import { cn } from "~/utils/cn";
@@ -76,7 +77,7 @@ export function Avatar({
/>
);
case "image":
return <AvatarImage avatar={avatar} size={size} />;
return <AvatarImage key={avatar.url} avatar={avatar} size={size} />;
}
}
@@ -190,20 +191,36 @@ function AvatarIcon({
}
function AvatarImage({ avatar, size }: { avatar: ImageAvatar; size: number }) {
if (!avatar.url) {
const [failed, setFailed] = useState(false);
// A server-rendered image can finish failing before hydration, so onError never fires.
const detectFailedLoad = useCallback((node: HTMLImageElement | null) => {
if (node && node.complete && node.naturalWidth === 0) {
setFailed(true);
}
}, []);
if (!avatar.url || failed) {
return (
<span className="grid shrink-0 place-items-center" style={styleFromSize(size)}>
<span
role="img"
aria-label="Organization avatar"
className="grid shrink-0 place-items-center overflow-hidden"
style={styleFromSize(size)}
>
<GlobeLinesIcon className="size-[90%] text-text-dimmed" />
</span>
);
}
return (
<span className="grid shrink-0 place-items-center" style={styleFromSize(size)}>
<span className="grid shrink-0 place-items-center overflow-hidden" style={styleFromSize(size)}>
<img
ref={detectFailedLoad}
src={avatar.url}
alt="Organization avatar"
className="size-full rounded-[10%] object-contain"
onError={() => setFailed(true)}
/>
</span>
);
@@ -22,7 +22,7 @@ const variants = {
},
"button/small": {
button:
"flex items-center w-fit h-8 pl-2 pr-3 rounded-md border border-border-bright/50 shadow-xs bg-secondary transition hover:bg-background-raised data-disabled:opacity-70 data-disabled:hover:bg-secondary hover:data-[state=checked]:bg-text-bright/4 data-[state=checked]:bg-text-bright/4",
"flex items-center w-fit h-8 pl-2 pr-3 rounded-md border border-border-bright/50 shadow-xs bg-background-bright transition hover:bg-surface-hover-subtle data-disabled:opacity-70 data-disabled:hover:bg-background-bright hover:data-[state=checked]:bg-surface-selected-hover data-[state=checked]:border-border-selected data-[state=checked]:bg-surface-selected",
label: "text-sm text-text-bright select-none",
description: "text-text-dimmed",
inputPosition: "mt-0",
@@ -30,7 +30,7 @@ const variants = {
},
button: {
button:
"w-fit py-2 pl-3 pr-4 rounded border border-border-bright/50 shadow-xs bg-secondary hover:bg-background-raised transition data-[state=checked]:bg-background-dimmed data-disabled:opacity-70",
"w-fit py-2 pl-3 pr-4 rounded border border-border-bright/50 shadow-xs bg-background-bright hover:bg-surface-hover-subtle transition hover:data-[state=checked]:bg-surface-selected-hover data-[state=checked]:border-border-selected data-[state=checked]:bg-surface-selected data-disabled:opacity-70",
label: "text-text-bright select-none",
description: "text-text-dimmed",
inputPosition: "mt-1",
@@ -38,7 +38,7 @@ const variants = {
},
description: {
button:
"w-full p-2.5 rounded-md border border-border-bright/50 shadow-xs bg-secondary transition hover:bg-background-raised data-disabled:opacity-70 hover:data-[state=checked]:bg-text-bright/4 data-[state=checked]:bg-text-bright/4",
"w-full p-2.5 rounded-md border border-border-bright/50 shadow-xs bg-background-bright transition hover:bg-surface-hover-subtle data-disabled:opacity-70 hover:data-[state=checked]:bg-surface-selected-hover data-[state=checked]:border-border-selected data-[state=checked]:bg-surface-selected",
label: "text-text-bright font-semibold -mt-0.5 text-left text-sm",
description: "text-text-dimmed mt-0 text-left",
inputPosition: "mt-0",
@@ -46,7 +46,7 @@ const variants = {
},
icon: {
button:
"w-full p-2.5 pb-4 rounded-sm border border-border-bright/50 shadow-xs bg-secondary hover:bg-background-raised transition data-disabled:opacity-70 data-[state=checked]:bg-background-dimmed",
"w-full p-2.5 pb-4 rounded-sm border border-border-bright/50 shadow-xs bg-background-bright hover:bg-surface-hover-subtle transition data-disabled:opacity-70 hover:data-[state=checked]:bg-surface-selected-hover data-[state=checked]:border-border-selected data-[state=checked]:bg-surface-selected",
label: "text-text-bright font-semibold -mt-1 text-left",
description: "text-text-dimmed mt-0 text-left",
inputPosition: "mt-0",
@@ -1028,7 +1028,7 @@ function PresetOptions({
id={`api-key-access-${preset.id}`}
value={preset.id}
variant="description"
className="h-full min-h-[3.5rem] items-start border-grid-bright bg-background-bright p-3 shadow-none [&_p]:mt-0.5 [&_p]:text-xs [&_p]:leading-snug hover:border-border-bright hover:bg-background-hover data-[state=checked]:border-indigo-500/70 data-[state=checked]:bg-indigo-500/10"
className="h-full min-h-[3.5rem] items-start border-grid-bright bg-background-bright p-3 shadow-none [&_p]:mt-0.5 [&_p]:text-xs [&_p]:leading-snug hover:border-border-bright hover:bg-background-hover data-[state=checked]:border-indigo-500/70 data-[state=checked]:bg-indigo-500/10 hover:data-[state=checked]:bg-indigo-500/15"
label={
preset.id === "FULL_ACCESS" ? (
<span className="flex items-center gap-2">
+19
View File
@@ -154,7 +154,10 @@
--color-background-dimmed: var(--color-charcoal-850);
--color-background-bright: var(--color-charcoal-800);
--color-background-hover: var(--color-charcoal-750);
--color-surface-hover-subtle: var(--color-charcoal-750);
--color-background-raised: var(--color-charcoal-700);
--color-surface-selected: var(--color-charcoal-650);
--color-surface-selected-hover: var(--color-charcoal-600);
--color-surface-control: var(--color-charcoal-600);
--color-surface-control-hover: var(--color-charcoal-550);
--color-surface-control-active: var(--color-charcoal-500);
@@ -163,6 +166,9 @@
/* Borders, from subtlest to most visible */
--color-grid-dimmed: var(--color-charcoal-750);
--color-grid-bright: var(--color-charcoal-700);
/* Blend of border-bright over the selected card, so it tracks whatever ramp
border-bright is on - the dark themes need no contrast entry of their own */
--color-border-selected: color-mix(in srgb, var(--color-border-bright) 50%, var(--color-surface-selected));
--color-border-bright: var(--color-charcoal-600);
--color-border-brighter: var(--color-charcoal-550);
--color-border-brightest: var(--color-charcoal-500);
@@ -766,14 +772,21 @@
--color-background-dimmed: #fbfbfc;
--color-background-bright: #ffffff;
--color-background-hover: #f2f3f5;
/* Cards on white need a gentler hover than the app-wide surfaces */
--color-surface-hover-subtle: #f7f8f9;
--color-background-raised: #e9eaee;
--color-surface-control: #dcdee3;
--color-surface-control-hover: #cfd2d9;
--color-surface-control-active: #b8bcc6;
/* Selection sits lighter than the controls here - on white a soft grey is
already enough to read as selected */
--color-surface-selected: #eff0f2;
--color-surface-selected-hover: #e7e9ec;
/* Borders */
--color-grid-dimmed: #eceef1;
--color-grid-bright: #e2e4e9;
--color-border-selected: #e0e2e6;
--color-border-bright: #d2d5db;
--color-border-brighter: #b9bdc7;
--color-border-brightest: #9ba1ad;
@@ -931,6 +944,7 @@
--color-background-dimmed: color-mix(in srgb, var(--color-charcoal-850), #000 calc(var(--theme-contrast, 0) * 55%));
--color-background-bright: color-mix(in srgb, var(--color-charcoal-800), #000 calc(var(--theme-contrast, 0) * 45%));
--color-background-hover: color-mix(in srgb, var(--color-charcoal-750), #000 calc(var(--theme-contrast, 0) * 35%));
--color-surface-hover-subtle: color-mix(in srgb, var(--color-charcoal-750), #000 calc(var(--theme-contrast, 0) * 35%));
--color-background-raised: color-mix(in srgb, var(--color-charcoal-700), #000 calc(var(--theme-contrast, 0) * 25%));
--color-input-bg: color-mix(in srgb, var(--color-charcoal-750), #000 calc(var(--theme-contrast, 0) * 35%));
@@ -955,6 +969,9 @@
page-behind surfaces darken a touch so panels separate */
--color-background-deep: color-mix(in srgb, #f1f2f4, #000 calc(var(--theme-contrast, 0) * 10%));
--color-background-hover: color-mix(in srgb, #f2f3f5, #000 calc(var(--theme-contrast, 0) * 8%));
/* Radio card surfaces take no ramp at all on white - their contrast is
carried by border-selected below. Deliberately absent: surface-hover-subtle,
surface-selected, surface-selected-hover. */
--color-background-raised: color-mix(in srgb, #e9eaee, #000 calc(var(--theme-contrast, 0) * 10%));
/* Controls and borders push hard toward black - this is where light-mode
@@ -964,6 +981,8 @@
--color-surface-control-active: color-mix(in srgb, #b8bcc6, #000 calc(var(--theme-contrast, 0) * 32%));
--color-grid-dimmed: color-mix(in srgb, #eceef1, #000 calc(var(--theme-contrast, 0) * 28%));
--color-grid-bright: color-mix(in srgb, #e2e4e9, #000 calc(var(--theme-contrast, 0) * 32%));
/* Steeper than border-bright so the selected card stays the loudest edge */
--color-border-selected: color-mix(in srgb, #e0e2e6, #000 calc(var(--theme-contrast, 0) * 45%));
--color-border-bright: color-mix(in srgb, #d2d5db, #000 calc(var(--theme-contrast, 0) * 38%));
--color-border-brighter: color-mix(in srgb, #b9bdc7, #000 calc(var(--theme-contrast, 0) * 42%));
--color-border-brightest: color-mix(in srgb, #9ba1ad, #000 calc(var(--theme-contrast, 0) * 46%));
+30 -3
View File
@@ -17,7 +17,11 @@ function directivePermits(directive: string, imageUrl: string): boolean {
if (!source.startsWith("http")) return false;
const parsed = new URL(source);
if (parsed.protocol !== url.protocol || parsed.host !== url.host) return false;
return parsed.pathname === "/" || parsed.pathname === url.pathname;
// CSP path matching: a source path ending in "/" matches by prefix, otherwise it
// must match exactly. The query string is never part of the match.
return parsed.pathname.endsWith("/")
? url.pathname.startsWith(parsed.pathname)
: parsed.pathname === url.pathname;
});
}
@@ -104,9 +108,9 @@ describe("parseCspImageOrigins", () => {
});
describe("buildImgSrcDirective", () => {
it("is self, data, blob, the SSO avatar hosts and the favicon endpoint by default", () => {
it("is self, data, blob, the SSO avatar hosts, the favicon endpoints and the changelog by default", () => {
expect(buildImgSrcDirective()).toBe(
"img-src 'self' data: blob: https://avatars.githubusercontent.com https://lh3.googleusercontent.com https://www.google.com/s2/favicons"
"img-src 'self' data: blob: https://avatars.githubusercontent.com https://lh3.googleusercontent.com https://www.google.com/s2/favicons https://t0.gstatic.com/faviconV2 https://t1.gstatic.com/faviconV2 https://t2.gstatic.com/faviconV2 https://t3.gstatic.com/faviconV2 https://trigger.dev/changelog/"
);
});
@@ -120,6 +124,29 @@ describe("buildImgSrcDirective", () => {
);
});
it("permits the gstatic shard the favicon endpoint redirects to", () => {
expect(
directivePermits(
buildImgSrcDirective(),
"https://t2.gstatic.com/faviconV2?url=https://example.com&size=128"
)
).toBe(true);
});
it("permits nothing else on a gstatic shard, and no shard we did not list", () => {
const directive = buildImgSrcDirective();
expect(directivePermits(directive, "https://t2.gstatic.com/beacon.png")).toBe(false);
expect(directivePermits(directive, "https://t9.gstatic.com/faviconV2")).toBe(false);
});
it("permits changelog images by path prefix, and nothing else on our domain", () => {
const directive = buildImgSrcDirective();
expect(directivePermits(directive, "https://trigger.dev/changelog/some-post/image.png")).toBe(
true
);
expect(directivePermits(directive, "https://trigger.dev/anything.png")).toBe(false);
});
it("permits both OAuth avatar hosts", () => {
const directive = buildImgSrcDirective();
expect(directivePermits(directive, "https://avatars.githubusercontent.com/u/1?v=4")).toBe(true);
+11 -3
View File
@@ -6,9 +6,12 @@
*/
/**
* Always allowed: own origin, inline data, object URLs, the SSO avatar hosts, and the
* favicon endpoint org avatars are stored as (see `utils/favicon.ts`). The path pins
* that one endpoint CSP matches the path and ignores the query string.
* Always allowed: own origin, inline data, object URLs, the SSO avatar hosts, the
* favicon endpoint org avatars are stored as (see `utils/favicon.ts`), and our own
* changelog images. The path pins each endpoint CSP matches the path and ignores the
* query string. The favicon endpoint 302s to a `tN.gstatic.com` shard and CSP re-checks
* only the host on a redirect, so the shards are listed too; their path pin limits
* direct loads only. A trailing "/" matches by prefix.
*/
export const BASE_IMG_SRC_SOURCES = [
"'self'",
@@ -17,6 +20,11 @@ export const BASE_IMG_SRC_SOURCES = [
"https://avatars.githubusercontent.com",
"https://lh3.googleusercontent.com",
"https://www.google.com/s2/favicons",
"https://t0.gstatic.com/faviconV2",
"https://t1.gstatic.com/faviconV2",
"https://t2.gstatic.com/faviconV2",
"https://t3.gstatic.com/faviconV2",
"https://trigger.dev/changelog/",
] as const;
export type RejectedOrigin = { value: string; reason: string };
@@ -24,6 +24,10 @@ describe("document image CSP", () => {
expect(directive).not.toMatch(/(^|\s)https?:(\s|$)/);
});
it("allows changelog images", () => {
expect(buildImgSrcDirective().split(" ")).toContain("https://trigger.dev/changelog/");
});
it("sets the header on every document response, not only on /login", () => {
// The set() call must sit outside the /login branch.
const loginBranch = source.slice(