Files
hanzo.ai/e2e/unit/prose.spec.ts
zeekay 6c65e3e941 @hanzo/app 0.2.0: the app SDK, renamed from @hanzo/rooms
Every import, the catalog pin and the docs name @hanzo/app and
github.com/hanzoai/app. lib/rooms.ts is lib/app.ts and
components/rooms.tsx is components/app.tsx.
2026-10-22 19:07:20 -07:00

91 lines
3.6 KiB
TypeScript

import { test, expect } from '@playwright/test'
import { marked } from 'marked'
import { destination, read } from '@hanzo/app/lib/prose'
/**
* A LINK IN A MESSAGE GOES WHERE A BROWSER WOULD GO, AND ONLY TO A SAFE PLACE.
*
* A shared chat renders the sharer's text as markdown, so a link in it is
* written by someone the reader never chose. A browser strips control
* characters and spaces from the ends of an href and tabs and newlines from
* inside it before it reads the scheme, so `\x01javascript:` runs script. The
* allowlist has to read the href the same way; these are the spellings that
* reached a `javascript:` link before it did.
*
* `components/workspace/Prose.tsx` draws a link token as an anchor exactly when
* `destination` answers, carrying the href it answers with, so the hrefs a
* message can put on the page are `destination` over every link token `read`
* produces — which is what this walks.
*/
const DANGEROUS = [
'[a](<\x01javascript:alert(1)>)',
'[a](\x01javascript:alert(1))',
'[a](<\x1fjavascript:alert(document.domain)>)',
'[a](<\u0000javascript:alert(1)>)',
'[a](&#x01;javascript:alert(1))',
'[a](&#1;javascript:alert(1))',
'[a](&#32;javascript:alert(1))',
'[a](<&#x9;javascript:alert(1)>)',
'[a]: <\x01javascript:alert(1)>\n\n[x][a]',
'<\x01javascript:alert(1)>',
'[a](javascript:alert(1))',
'[a](JaVaScRiPt:alert(1))',
'[a](&#106;avascript:alert(1))',
'[a](<\x0bjavascript:alert(1)>)',
'[a](<\x0cjavascript:alert(1)>)',
'[a](\\\x01javascript:alert(1))',
'[a](<java\tscript:alert(1)>)',
'[a](<java\nscript:alert(1)>)',
'[a](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)',
'[a](vbscript:msgbox(1))',
]
/** Every link a message would draw: the href and whether it opens away. */
function links(text: string): { href: string; away: boolean }[] {
const out: { href: string; away: boolean }[] = []
marked.walkTokens(read(text), (t) => {
if (t.type !== 'link') return
const to = destination(t.href)
if (to) out.push(to)
})
return out
}
/** The scheme a browser follows for an href it was handed as an attribute
* value: the URL parser's own stripping, then the parse. */
function scheme(href: string): string {
const bare = href.replace(/[\t\n\r]/g, '').replace(/^[\u0000- ]+|[\u0000- ]+$/g, '')
try {
return new URL(bare, 'https://hanzo.ai/').protocol
} catch {
return 'unparseable:'
}
}
test.describe('prose links', () => {
for (const c of DANGEROUS) {
test(`no script from ${JSON.stringify(c)}`, () => {
for (const { href } of links(c)) {
expect(['http:', 'https:', 'mailto:', 'tel:'], `${JSON.stringify(c)} drew ${JSON.stringify(href)}`).toContain(scheme(href))
}
})
}
// A link that leaves the page opens away from it — a new tab, no opener, no
// referrer — however its host is spelled. (Regression: `/\host` resolves to
// another site and was treated as a link back into this one.)
for (const href of ['//evil.example/x', '/\\evil.example/x', 'https://evil.example/', 'HTTPS://EVIL.EXAMPLE']) {
test(`${JSON.stringify(href)} opens away`, () => {
expect(links(`[a](${href})`)).toEqual([expect.objectContaining({ away: true })])
})
}
test('ordinary links still link', () => {
expect(links('[docs](https://hanzo.ai/docs)')).toEqual([{ href: 'https://hanzo.ai/docs', away: true }])
expect(links('[mail](mailto:[email protected])')).toEqual([{ href: 'mailto:[email protected]', away: true }])
expect(links('[home](/chat)')).toEqual([{ href: '/chat', away: false }])
expect(links('[top](#top)')).toEqual([{ href: '#top', away: false }])
})
})