Skip to content

Commit 1a85b0d

Browse files
committed
fix: validate opaque PR image media types
1 parent e2f40e2 commit 1a85b0d

2 files changed

Lines changed: 157 additions & 20 deletions

File tree

‎apps/api/src/handlers/__tests__/merge-announcer-push.test.ts‎

Lines changed: 54 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎apps/api/src/handlers/merge-announcer-push.ts‎

Lines changed: 103 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ const MAX_GITHUB_PULL_REQUEST_CANDIDATES = 3;
1515
const MAX_GITHUB_CHANGED_FILES = 20;
1616
const MAX_GITHUB_PULL_REQUEST_IMAGE_CANDIDATES = 20;
1717
const MAX_GITHUB_PULL_REQUEST_IMAGE_ALT_CHARS = 200;
18+
const MAX_GITHUB_IMAGE_MEDIA_TYPE_CHECKS = 2;
19+
const MAX_GITHUB_IMAGE_REDIRECTS = 3;
20+
const GITHUB_IMAGE_MEDIA_TYPE_TIMEOUT_MS = 3_000;
1821
const MARKDOWN_IMAGE_PATTERN =
1922
/!\[([^\]]*)\]\(\s*(?:<([^>\n]+)>|([^\s)\n]+))(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/gu;
2023
const HTML_IMAGE_PATTERN = /<img\b[^>]*>/giu;
@@ -24,6 +27,11 @@ const SCREENSHOT_LIKE_IMAGE_TEXT =
2427
/\b(?:after|before|demo|desktop|mobile|preview|screen(?:[ -]?shot)?|ui)\b/iu;
2528
const REJECTED_IMAGE_TEXT = /\b(?:avatar|badge|coverage|icon|logo|shield)\b/iu;
2629
const SUPPORTED_SLACK_IMAGE_EXTENSION = /\.(?:gif|jpe?g|png)$/iu;
30+
const SUPPORTED_SLACK_IMAGE_MEDIA_TYPES = new Set([
31+
'image/gif',
32+
'image/jpeg',
33+
'image/png',
34+
]);
2735
const EXTENSION_REQUIRED_GITHUB_IMAGE_HOSTS = new Set([
2836
'raw.githubusercontent.com',
2937
'user-images.githubusercontent.com',
@@ -62,6 +70,58 @@ function isAllowedPublicGitHubImageUrl(value: string): boolean {
6270
}
6371
}
6472

73+
function isExtensionlessGitHubAttachmentUrl(value: string): boolean {
74+
const url = new URL(value);
75+
return (
76+
url.hostname.toLowerCase() === 'github.com' &&
77+
url.pathname.startsWith('/user-attachments/assets/') &&
78+
!url.pathname.includes('.')
79+
);
80+
}
81+
82+
function isAllowedGitHubImageRedirect(url: URL): boolean {
83+
const hostname = url.hostname.toLowerCase();
84+
return (
85+
url.protocol === 'https:' &&
86+
!url.username &&
87+
!url.password &&
88+
(hostname === 'github.com' || hostname.endsWith('.githubusercontent.com'))
89+
);
90+
}
91+
92+
async function getPublicGitHubMediaType(value: string): Promise<string | null> {
93+
let url = new URL(value);
94+
const signal = AbortSignal.timeout(GITHUB_IMAGE_MEDIA_TYPE_TIMEOUT_MS);
95+
for (
96+
let redirects = 0;
97+
redirects <= MAX_GITHUB_IMAGE_REDIRECTS;
98+
redirects++
99+
) {
100+
const response = await fetch(url, {
101+
method: 'HEAD',
102+
redirect: 'manual',
103+
signal,
104+
});
105+
if (response.status >= 300 && response.status < 400) {
106+
const location = response.headers.get('location');
107+
if (!location || redirects === MAX_GITHUB_IMAGE_REDIRECTS) return null;
108+
url = new URL(location, url);
109+
if (!isAllowedGitHubImageRedirect(url)) return null;
110+
continue;
111+
}
112+
if (!response.ok) return null;
113+
114+
return (
115+
response.headers
116+
.get('content-type')
117+
?.split(';', 1)[0]
118+
?.trim()
119+
.toLowerCase() ?? null
120+
);
121+
}
122+
return null;
123+
}
124+
65125
function normalizePullRequestImageCandidate(
66126
candidate: PullRequestImageCandidate,
67127
): PullRequestImageCandidate | null {
@@ -79,9 +139,12 @@ function normalizePullRequestImageCandidate(
79139
};
80140
}
81141

82-
export function selectRepresentativeGitHubPullRequestImage(
142+
export async function selectRepresentativeGitHubPullRequestImage(
83143
body: string | null | undefined,
84-
): MergeAnnouncerPullRequestContext['representativeImage'] | null {
144+
resolveMediaType: (
145+
url: string,
146+
) => Promise<string | null> = getPublicGitHubMediaType,
147+
): Promise<MergeAnnouncerPullRequestContext['representativeImage'] | null> {
85148
if (!body?.trim()) return null;
86149

87150
const candidates: PullRequestImageCandidate[] = [];
@@ -101,7 +164,7 @@ export function selectRepresentativeGitHubPullRequestImage(
101164
});
102165
}
103166

104-
const selected = candidates
167+
const rankedCandidates = candidates
105168
.sort((a, b) => a.position - b.position)
106169
.slice(0, MAX_GITHUB_PULL_REQUEST_IMAGE_CANDIDATES)
107170
.map(normalizePullRequestImageCandidate)
@@ -111,9 +174,38 @@ export function selectRepresentativeGitHubPullRequestImage(
111174
Number(SCREENSHOT_LIKE_IMAGE_TEXT.test(b.altText)) -
112175
Number(SCREENSHOT_LIKE_IMAGE_TEXT.test(a.altText));
113176
return screenshotScoreDifference || a.position - b.position;
114-
})[0];
177+
});
115178

116-
return selected ? { url: selected.url, altText: selected.altText } : null;
179+
const mediaTypeResults = await Promise.allSettled(
180+
rankedCandidates
181+
.filter((candidate) => isExtensionlessGitHubAttachmentUrl(candidate.url))
182+
.slice(0, MAX_GITHUB_IMAGE_MEDIA_TYPE_CHECKS)
183+
.map(async (candidate) => ({
184+
url: candidate.url,
185+
mediaType: await resolveMediaType(candidate.url),
186+
})),
187+
);
188+
const mediaTypes = new Map(
189+
mediaTypeResults.flatMap((result) =>
190+
result.status === 'fulfilled'
191+
? [[result.value.url, result.value.mediaType] as const]
192+
: [],
193+
),
194+
);
195+
196+
for (const candidate of rankedCandidates) {
197+
if (isExtensionlessGitHubAttachmentUrl(candidate.url)) {
198+
const mediaType = mediaTypes.get(candidate.url);
199+
if (
200+
!mediaType ||
201+
!SUPPORTED_SLACK_IMAGE_MEDIA_TYPES.has(mediaType.toLowerCase())
202+
) {
203+
continue;
204+
}
205+
}
206+
return { url: candidate.url, altText: candidate.altText };
207+
}
208+
return null;
117209
}
118210

119211
function getPullRequestNumberFromCommitMessage(
@@ -161,11 +253,13 @@ type GitHubPushWebhook = {
161253

162254
type GitHubMergeAnnouncerDependencies = {
163255
getInstallationOctokit: typeof getInstallationOctokit;
256+
getPublicMediaType: typeof getPublicGitHubMediaType;
164257
selectRepresentativeImage: typeof selectRepresentativeGitHubPullRequestImage;
165258
};
166259

167260
const githubMergeAnnouncerDependencies: GitHubMergeAnnouncerDependencies = {
168261
getInstallationOctokit,
262+
getPublicMediaType: getPublicGitHubMediaType,
169263
selectRepresentativeImage: selectRepresentativeGitHubPullRequestImage,
170264
};
171265

@@ -276,7 +370,10 @@ export async function enrichGitHubMergeAnnouncerEvent(
276370
if (payload.repository?.private === false) {
277371
try {
278372
representativeImage =
279-
dependencies.selectRepresentativeImage(pullRequest.body) ?? undefined;
373+
(await dependencies.selectRepresentativeImage(
374+
pullRequest.body,
375+
dependencies.getPublicMediaType,
376+
)) ?? undefined;
280377
} catch (error) {
281378
console.warn(
282379
`[mergeAnnouncer] Failed to select a pull request image for ${payload.repository.full_name}#${pullRequest.number}: ${error instanceof Error ? error.message : String(error)}`,

0 commit comments

Comments
 (0)