Skip to content

Commit a87bfde

Browse files
committed
fix(favicons): 204 for no icon, only 404 is a provider miss, reject empty labels and non-DNS hostnames
A miss is an ordinary answer, so it returns 204 (which an img still reports as failed) instead of an error status; binary internal routes may now declare several success statuses and present a no-content response, sharing the JSON builder's status validation. Providers are asked one at a time so a second only learns a host the first had no icon for, a non-200 body is never read, and anything but 404/410 counts as unavailable rather than a cached miss.
1 parent 6c5ba2e commit a87bfde

12 files changed

Lines changed: 113 additions & 64 deletions

File tree

‎apps/sim/app/api/favicon/route.ts‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,22 @@ export const GET = defineInternalBinaryRoute({
1919
errorPolicy: internalOrchestrationErrorPolicy,
2020
mapInput: ({ query }) => ({ host: query.host }),
2121
useCase: getFavicon,
22-
present: ({ bytes, contentType }) => ({
23-
body: new Uint8Array(bytes),
24-
contentType,
25-
contentLength: bytes.length,
26-
headers: {
27-
'Cache-Control': 'private, max-age=86400, stale-while-revalidate=604800',
28-
'X-Content-Type-Options': 'nosniff',
29-
},
30-
}),
22+
/** No icon is an ordinary answer, not an error: 204, which an `<img>` still reports as failed. */
23+
present: (favicon) =>
24+
favicon.kind === 'hit'
25+
? {
26+
body: new Uint8Array(favicon.bytes),
27+
contentType: favicon.contentType,
28+
contentLength: favicon.bytes.length,
29+
headers: {
30+
'Cache-Control': 'private, max-age=86400, stale-while-revalidate=604800',
31+
'X-Content-Type-Options': 'nosniff',
32+
},
33+
}
34+
: {
35+
status: 204,
36+
headers: {
37+
'Cache-Control': favicon.kind === 'miss' ? 'private, max-age=3600' : 'no-store',
38+
},
39+
},
3140
})

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-icon.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,6 @@ describe('source hostname branding', () => {
2929
container.innerHTML = renderToStaticMarkup(<SourceIcon source={{ url }} />)
3030
const image = container.querySelector('img')
3131
expect(image).not.toBeNull()
32-
expect(new URL(image!.src).searchParams.get('domain')).toBe(new URL(url).hostname)
32+
expect(new URL(image!.src).searchParams.get('host')).toBe(new URL(url).hostname)
3333
})
3434
})

‎apps/sim/lib/api/contracts/favicon.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { z } from 'zod'
22
import { defineRouteContract } from '@/lib/api/contracts/types'
33

44
/**
5-
* A site's icon by hostname: image bytes, or 404 when none can be served — neither the host nor
5+
* A site's icon by hostname: image bytes, or 204 when none can be served — neither the host nor
66
* a parent has one, the host is internal, or the providers are unavailable.
77
*/
88
export const getFaviconContract = defineRouteContract({
@@ -15,5 +15,5 @@ export const getFaviconContract = defineRouteContract({
1515
.min(1, 'host is required')
1616
.max(253, 'host must be 253 characters or less'),
1717
}),
18-
response: { mode: 'binary' },
18+
response: { mode: 'binary', status: [200, 204] },
1919
})

‎apps/sim/lib/api/server/routes/definition.ts‎

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,27 @@ export function requireJsonRouteDefinition(
2020
)
2121
}
2222

23+
return declaredSuccessStatuses(contract)
24+
}
25+
26+
export function requireBinaryRouteDefinition(
27+
contract: AnyApiRouteContract,
28+
declaredOperation: ApplicationOperation,
29+
useCaseOperation: ApplicationOperation
30+
): JsonRouteDefinitionMetadata {
31+
if (contract.response.mode !== 'binary') {
32+
throw new Error(`${contract.method} ${contract.path} requires a binary response contract`)
33+
}
34+
if (declaredOperation.id !== useCaseOperation.id) {
35+
throw new Error(
36+
`Route operation ${declaredOperation.id} does not match use case ${useCaseOperation.id}`
37+
)
38+
}
39+
return declaredSuccessStatuses(contract)
40+
}
41+
42+
/** The contract's success statuses, 200 by default; the first is a route's default status. */
43+
function declaredSuccessStatuses(contract: AnyApiRouteContract): JsonRouteDefinitionMetadata {
2344
const configuredStatus = contract.response.status
2445
const successStatuses =
2546
configuredStatus === undefined
@@ -41,30 +62,6 @@ export function requireJsonRouteDefinition(
4162
return { successStatus: successStatuses[0], successStatuses }
4263
}
4364

44-
export function requireBinaryRouteDefinition(
45-
contract: AnyApiRouteContract,
46-
declaredOperation: ApplicationOperation,
47-
useCaseOperation: ApplicationOperation
48-
): JsonRouteDefinitionMetadata {
49-
if (contract.response.mode !== 'binary') {
50-
throw new Error(`${contract.method} ${contract.path} requires a binary response contract`)
51-
}
52-
if (declaredOperation.id !== useCaseOperation.id) {
53-
throw new Error(
54-
`Route operation ${declaredOperation.id} does not match use case ${useCaseOperation.id}`
55-
)
56-
}
57-
const configuredStatus = contract.response.status
58-
if (configuredStatus !== undefined && typeof configuredStatus !== 'number') {
59-
throw new Error(`${contract.method} ${contract.path} must declare one success status`)
60-
}
61-
const successStatus = configuredStatus ?? 200
62-
if (successStatus < 200 || successStatus >= 300) {
63-
throw new Error(`${contract.method} ${contract.path} has a non-success response status`)
64-
}
65-
return { successStatus, successStatuses: [successStatus] }
66-
}
67-
6865
/**
6966
* Whether an incoming request's method is the one its contract declares.
7067
*

‎apps/sim/lib/api/server/routes/internal-binary-route.ts‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
JsonErrorResponseDescriptor,
2020
JsonNextRouteHandler,
2121
JsonRouteContext,
22+
NoContentResponseDescriptor,
2223
} from '@/lib/api/server/routes/types'
2324
import type { ParsedRequest, ParseRequestOptions } from '@/lib/api/server/validation'
2425
import { parseRequest } from '@/lib/api/server/validation'
@@ -35,7 +36,12 @@ interface InternalBinaryRouteDefinition<
3536
operation: O
3637
mapInput(input: ParsedRequest<C>): I
3738
useCase: OperationUseCase<NoInfer<O>, I, R>
38-
present(result: R): BinaryResponseDescriptor | Promise<BinaryResponseDescriptor>
39+
present(
40+
result: R
41+
):
42+
| BinaryResponseDescriptor
43+
| NoContentResponseDescriptor
44+
| Promise<BinaryResponseDescriptor | NoContentResponseDescriptor>
3945
}
4046

4147
interface InternalBinaryRouteOptions<
@@ -64,7 +70,7 @@ export function defineInternalBinaryRoute<
6470
I,
6571
R,
6672
>(options: InternalBinaryRouteOptions<C, O, I, R>): JsonNextRouteHandler {
67-
const { successStatus } = requireBinaryRouteDefinition(
73+
const { successStatus, successStatuses } = requireBinaryRouteDefinition(
6874
options.contract,
6975
options.operation,
7076
options.useCase.operation
@@ -116,6 +122,14 @@ export function defineInternalBinaryRoute<
116122
const result = await options.useCase.execute({ principal, input, request })
117123
const descriptor = await options.present(result)
118124
await options.onSuccess?.({ principal, input, result })
125+
if ('status' in descriptor) {
126+
if (!successStatuses.includes(descriptor.status)) {
127+
throw new Error(
128+
`Internal binary route produced undeclared success status ${descriptor.status}; expected ${successStatuses.join(', ')}`
129+
)
130+
}
131+
return new NextResponse(null, { status: descriptor.status, headers: descriptor.headers })
132+
}
119133
const headers = new Headers(descriptor.headers)
120134
headers.set('Content-Type', descriptor.contentType)
121135
if (descriptor.contentDisposition) {

‎apps/sim/lib/api/server/routes/types.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ export interface BinaryResponseDescriptor {
3030
headers?: HeadersInit
3131
}
3232

33+
/** An empty success from a binary route whose contract declares 204. */
34+
export interface NoContentResponseDescriptor {
35+
status: 204
36+
headers?: HeadersInit
37+
}
38+
3339
export interface JsonErrorResponseDescriptor {
3440
body: unknown
3541
status: number
Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,14 @@
11
import { defineOperation } from '@/lib/core/application'
2-
import { OrchestrationError } from '@/lib/core/orchestration/types'
32
import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case'
43
import { resolveFavicon } from '@/lib/favicon/resolve.server'
54

6-
/** A site's icon for the signed-in user; a host with none anywhere is `not_found`. */
5+
/** A site's icon for the signed-in user, or why there is none to serve. */
76
export const getFavicon = defineAuthorizedCredentialUserUseCase({
87
// permission-group-exempt: A public site's icon reveals nothing the caller's permissions govern.
98
operation: defineOperation({
109
id: 'favicons.get',
1110
principalKinds: ['session'],
1211
capability: 'none',
1312
}),
14-
async execute({ input }: { input: { host: string } }) {
15-
const favicon = await resolveFavicon(input.host)
16-
if (favicon.kind !== 'hit') throw new OrchestrationError('not_found', 'No icon for this host')
17-
return favicon
18-
},
13+
execute: ({ input }: { input: { host: string } }) => resolveFavicon(input.host),
1914
})

‎apps/sim/lib/favicon/hosts.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,27 @@ describe('faviconHostCandidates', () => {
6060
expect(faviconHostCandidates('bücher.de')).toEqual(['xn--bcher-kva.de'])
6161
})
6262

63+
it.each(['x.com&size=1.com', 'a&b=c.com', 'a%26b.com', "it's.com", 'a(b).com', 'a+b.com'])(
64+
'offers nothing for %j, whose punctuation would reshape a provider URL',
65+
(host) => {
66+
expect(faviconHostCandidates(host)).toEqual([])
67+
}
68+
)
69+
70+
it.each(['example.com..', '.example.com', 'docs..example.com'])(
71+
'offers nothing for %j, which has an empty label',
72+
(host) => {
73+
expect(faviconHostCandidates(host)).toEqual([])
74+
}
75+
)
76+
77+
it('keeps an underscore, which some real hosts use', () => {
78+
expect(faviconHostCandidates('my_app.example.com')).toEqual([
79+
'my_app.example.com',
80+
'example.com',
81+
])
82+
})
83+
6384
it.each(['', 'https://example.com/path', 'exa mple.com', 'user@example.com', 'com'])(
6485
'offers nothing for %j',
6586
(host) => {

‎apps/sim/lib/favicon/hosts.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,18 @@ const HOST_SHAPE = /^[^\s/?#@\\]+$/
88
* `acme.atlassian.net` is invisible to providers, but its platform is not, so each parent
99
* domain follows, stopping before the ICANN public suffix (`gitlab.io` is a valid last step;
1010
* `io` and `co.uk` are not). Internal hosts — IP literals, single labels, and names outside
11-
* the public DNS — yield nothing, so they never leave the server.
11+
* the public DNS — yield nothing, so they never leave the server, and neither does a name with
12+
* characters DNS can't hold, which could otherwise reshape a provider's URL.
1213
*/
1314
export function faviconHostCandidates(input: string): string[] {
1415
const url = HOST_SHAPE.test(input) ? URL.parse(`https://${input}`) : null
1516
if (!url) return []
1617
const hostname = url.hostname.replace(/\.$/, '')
17-
const { isIp, isIcann, publicSuffix } = parse(hostname, { allowPrivateDomains: false })
18+
if (hostname.split('.').some((label) => !label)) return []
19+
const { isIp, isIcann, publicSuffix } = parse(hostname, {
20+
allowPrivateDomains: false,
21+
validateHostname: true,
22+
})
1823
if (isIp || !isIcann || !publicSuffix || /(^|\.)arpa$/.test(publicSuffix)) return []
1924
const labels = hostname.split('.')
2025
const candidateCount = labels.length - publicSuffix.split('.').length

‎apps/sim/lib/favicon/probe.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,11 @@ describe('classifyProviderResponse', () => {
3838
expect(classifyProviderResponse(200, oversized)).toEqual({ kind: 'miss' })
3939
})
4040

41-
it.each([429, 500, 502, 503])('treats %i as unavailable, not as a miss', (status) => {
41+
it('counts a 410 as a miss, like a 404', () => {
42+
expect(classifyProviderResponse(410, PNG)).toEqual({ kind: 'miss' })
43+
})
44+
45+
it.each([403, 408, 429, 500, 502, 503])('treats %i as unavailable, not as a miss', (status) => {
4246
expect(classifyProviderResponse(status, PNG)).toEqual({ kind: 'unavailable' })
4347
})
4448
})

0 commit comments

Comments
 (0)