Skip to content

Commit 7a7e759

Browse files
committed
fix(sim-links): harden destinations against dot segments, control characters, and off-site defaults
1 parent 3dfac32 commit 7a7e759

7 files changed

Lines changed: 74 additions & 17 deletions

File tree

‎apps/docs/content/docs/search/sim-links.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ To place a value somewhere else, put `{*}` in the destination URL:
4949
| `https://wiki.example.com/search?q={*}` | `sim/wiki/expense policy` | `https://wiki.example.com/search?q=expense%20policy` |
5050
| `https://github.com/acme/{*}/issues/{*}` | `sim/issues/api/42` | `https://github.com/acme/api/issues/42` |
5151

52-
Each value fills the next `{*}` in order, and the last `{*}` takes everything that remains. A link with `{*}` can also have a **Default destination**, which opens when you use the link without a value. `{*}` can't replace the domain.
52+
Each value fills the next `{*}` in order, and the last `{*}` takes everything that remains. A link with `{*}` can also have a **Default destination** on the same site, which opens when you use the link without a value. `{*}` can't replace the domain, and values can't be `.` or `..`.
5353

5454
## Control access
5555

‎apps/sim/app/sim/[[...path]]/page.tsx‎

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { getSession } from '@/lib/auth'
55
import { getActiveOrganizationId } from '@/lib/auth/session-response'
66
import { organizationRoutes, simLinkPath } from '@/lib/navigation/paths'
77
import { resolveOrganizationLanding } from '@/lib/organizations/surface'
8-
import { resolveSimLink } from '@/lib/sim-links/application/use-cases'
8+
import { resolveSimLink, type SimLinkResolution } from '@/lib/sim-links/application/use-cases'
99
import { isSimLinksEnabled } from '@/lib/sim-links/feature-flag'
1010
import { simLinkNameSchema } from '@/lib/sim-links/validation'
1111
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
@@ -32,6 +32,20 @@ function decodeSimLinkPath(path: string[]): string[] {
3232
return segments.length === 1 ? segments[0].split('/').filter(Boolean) : segments
3333
}
3434

35+
/**
36+
* Why a link did not redirect. Only a valid link name is echoed back, so an arbitrary path
37+
* can't put its own text on this page.
38+
*/
39+
function describeUnresolved(result: SimLinkResolution, validName: boolean, name: string): string {
40+
if (result.kind === 'missing_values') {
41+
const example = Array.from({ length: result.required }, () => 'value').join('/')
42+
const needed = result.required === 1 ? 'a value' : `${result.required} values`
43+
return `This link needs ${needed}, like sim/${name}/${example}.`
44+
}
45+
if (result.kind === 'invalid_values') return "Sim links can't open . or .. as a value."
46+
return validName ? "This Sim link doesn't exist yet." : "That isn't a valid Sim link name."
47+
}
48+
3549
/**
3650
* Where `sim/<name>/<values…>` lands: the viewer's organization link's destination,
3751
* or a page that offers to create the link when nobody has yet.
@@ -62,16 +76,13 @@ export default async function SimLinkPage({ params }: SimLinkPageProps) {
6276
})
6377
if (result.kind === 'redirect') redirect(result.url)
6478

65-
const creatable = result.kind === 'not_found' && simLinkNameSchema.safeParse(name).success
79+
const validName = simLinkNameSchema.safeParse(name).success
80+
const creatable = result.kind === 'not_found' && validName
6681
return (
6782
<LogoShell center theme='inherit'>
6883
<StatusPageContent
69-
title={`sim/${name}`}
70-
description={
71-
result.kind === 'missing_values'
72-
? `This link needs ${result.required === 1 ? 'a value' : `${result.required} values`}, like sim/${name}/${Array.from({ length: result.required }, () => 'value').join('/')}.`
73-
: "This Sim link doesn't exist yet."
74-
}
84+
title={validName ? `sim/${name}` : 'Sim link'}
85+
description={describeUnresolved(result, validName, name)}
7586
>
7687
{creatable && (
7788
<ChipLink variant='primary' href={serializeSimLinkView(library, { create: name })}>

‎apps/sim/lib/sim-links/application/use-cases.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export interface SimLinkView extends SimLinkRecord {
3232
canManage: boolean
3333
}
3434

35-
type SimLinkResolution = SimLinkTarget | { kind: 'not_found' }
35+
export type SimLinkResolution = SimLinkTarget | { kind: 'not_found' }
3636

3737
const rollout = {
3838
authorizeResource: ({ context }: { context: OrganizationMembershipContext }) =>
@@ -112,7 +112,12 @@ function auditOf(
112112

113113
/** What a saved link now does, for its audit entry. */
114114
function savedMetadata(link: SimLinkView): Record<string, unknown> {
115-
return { url: link.url, unlisted: link.unlisted, restrictEdit: link.restrictEdit }
115+
return {
116+
url: link.url,
117+
defaultUrl: link.defaultUrl,
118+
unlisted: link.unlisted,
119+
restrictEdit: link.restrictEdit,
120+
}
116121
}
117122

118123
/** Read in the writing transaction, so the result and its audit describe exactly this write. */

‎apps/sim/lib/sim-links/destination.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ describe('Sim link destinations', () => {
3535
'ftp://example.com/file',
3636
'//example.com/path',
3737
'example.com',
38+
'https://\t/{*}',
39+
'https://\n/{*}',
40+
'https://example.com/\r{*}',
3841
])('rejects %s, whose origin is not a fixed http(s) origin', (url) => {
3942
expect(acceptsDestination(url)).toBe(false)
4043
})
@@ -53,6 +56,9 @@ describe('Sim link destinations', () => {
5356
expect(acceptsDestination('https://example.com', 'https://example.com/other')).toBe(false)
5457
expect(acceptsDestination('https://example.com/{*}', 'https://example.com/{*}')).toBe(false)
5558
expect(acceptsDestination('https://example.com/{*}', 'javascript:alert(1)')).toBe(false)
59+
expect(acceptsDestination('https://example.com/{*}', 'https://elsewhere.example.org')).toBe(
60+
false
61+
)
5662
})
5763

5864
it.each(['//evil.com', '@evil.com', '../../admin', '?x=1#y', '%2Fetc', 'a:b'])(
@@ -74,6 +80,17 @@ describe('Sim link destinations', () => {
7480
}
7581
)
7682

83+
it.each([[['..', '..', 'attacker', 'phish']], [['.']], [['a', '..']]])(
84+
'refuses dot segments in %j, which would climb out of the link path',
85+
(values) => {
86+
for (const url of ['https://github.com/acme/{*}', 'https://github.com/acme/handbook']) {
87+
expect(resolveSimLinkTarget({ url, defaultUrl: null }, values)).toEqual({
88+
kind: 'invalid_values',
89+
})
90+
}
91+
}
92+
)
93+
7794
it('fills each variable in order, the last taking every remaining value', () => {
7895
expect(redirectUrl('https://github.com/{*}/issues/{*}', ['sim', 'BUG 12', 'x'])).toBe(
7996
'https://github.com/sim/issues/BUG%2012/x'

‎apps/sim/lib/sim-links/destination.ts‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ interface SimLinkDestination {
1313
export type SimLinkTarget =
1414
| { kind: 'redirect'; url: string }
1515
| { kind: 'missing_values'; required: number }
16+
| { kind: 'invalid_values' }
17+
18+
/** URL parsers resolve `.` and `..` even when encoded, so a value could climb out of the link's path. */
19+
function isDotSegment(value: string): boolean {
20+
return value === '.' || value === '..'
21+
}
1622

1723
function encodeSegments(values: readonly string[]): string {
1824
return values.map(encodeURIComponent).join('/')
@@ -37,17 +43,14 @@ export function resolveSimLinkTarget(
3743
): SimLinkTarget {
3844
const origin = simLinkOrigin(link.url)
3945
if (!origin) throw new Error('Sim link destination has no fixed http(s) origin')
46+
if (values.some(isDotSegment)) return { kind: 'invalid_values' }
4047
if (!isVariableSimLinkUrl(link.url)) {
4148
if (!values.length) return redirectWithin(origin, link.url)
4249
const url = new URL(link.url)
4350
url.pathname = `${url.pathname.replace(/\/+$/, '')}/${encodeSegments(values)}`
4451
return redirectWithin(origin, url.toString())
4552
}
46-
if (!values.length && link.defaultUrl) {
47-
const defaultUrl = parseHttpUrl(link.defaultUrl)
48-
if (!defaultUrl) throw new Error('Sim link default destination is not an http(s) URL')
49-
return { kind: 'redirect', url: defaultUrl.toString() }
50-
}
53+
if (!values.length && link.defaultUrl) return redirectWithin(origin, link.defaultUrl)
5154
const parts = link.url.split(SIM_LINK_VARIABLE)
5255
const slots = parts.length - 1
5356
if (values.length < slots) return { kind: 'missing_values', required: slots }

‎apps/sim/lib/sim-links/validation.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,15 @@ export function parseHttpUrl(value: string): URL | null {
3131
/** A scheme and authority closed by `/`, `?`, or `#`: nothing typed later can extend the host. */
3232
const CLOSED_AUTHORITY = /^https?:\/\/[^/?#]+[/?#]/i
3333

34+
/** URL parsers strip tabs and line breaks, so one inside the authority could hide where it ends. */
35+
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/
36+
3437
/**
3538
* The origin every visit to `url` must land on, or null when values could change it. Values fill
3639
* only what follows the authority, so the text before the first `{*}` must already close it.
3740
*/
3841
export function simLinkOrigin(url: string): string | null {
42+
if (CONTROL_CHARACTER.test(url)) return null
3943
const [prefix] = url.split(SIM_LINK_VARIABLE)
4044
if (isVariableSimLinkUrl(url) && !CLOSED_AUTHORITY.test(prefix)) return null
4145
return parseHttpUrl(url.replaceAll(SIM_LINK_VARIABLE, 'value'))?.origin ?? null
@@ -61,7 +65,7 @@ const simLinkUrlSchema = destinationSchema.refine(
6165
)
6266

6367
const defaultUrlSchema = destinationSchema.refine(
64-
(url) => !isVariableSimLinkUrl(url) && parseHttpUrl(url) !== null,
68+
(url) => !isVariableSimLinkUrl(url) && simLinkOrigin(url) !== null,
6569
'Enter a full http:// or https:// URL'
6670
)
6771

@@ -88,5 +92,10 @@ export const simLinkFieldsSchema = z
8892
path: ['defaultUrl'],
8993
message: `Only a link with ${SIM_LINK_VARIABLE} has a default destination`,
9094
})
95+
.refine(
96+
(fields) =>
97+
!fields.defaultUrl || simLinkOrigin(fields.defaultUrl) === simLinkOrigin(fields.url),
98+
{ path: ['defaultUrl'], message: 'Use a page on the same site as the destination URL' }
99+
)
91100

92101
export type SimLinkFields = z.output<typeof simLinkFieldsSchema>

‎apps/sim/scripts/test-sim-links-e2e.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,13 +233,16 @@ async function run() {
233233
)
234234
await create(owner, { name: 'gh', url: 'https://github.com/{*}/issues/{*}' })
235235
assert.match((await open(member, 'gh/sim')).text, /needs 2 values/)
236+
assert.match((await open(member, 'jira%2F..%2F..%2Fadmin')).text, /can.*t open/)
237+
assert.doesNotMatch((await open(member, 'not%20a%20link%20name')).text, /not a link name/)
236238
})
237239

238240
await check('destinations must be http(s) with a fixed origin', async () => {
239241
for (const url of [
240242
'javascript:alert(1)',
241243
'https://{*}.example.com',
242244
'https://{*}@corp.example.com/',
245+
'https://\t/{*}',
243246
'example.com',
244247
]) {
245248
await api(owner, '', { method: 'POST', body: fields({ name: 'bad', url }), expected: 400 })
@@ -253,6 +256,15 @@ async function run() {
253256
}),
254257
expected: 400,
255258
})
259+
await api(owner, '', {
260+
method: 'POST',
261+
body: fields({
262+
name: 'bad',
263+
url: 'https://example.com/{*}',
264+
defaultUrl: 'https://elsewhere.example.org',
265+
}),
266+
expected: 400,
267+
})
256268
})
257269

258270
await check('names differing only in case or punctuation conflict', async () => {

0 commit comments

Comments
 (0)