security: don't send referrer paths for images and bookmarks (#3881)

We're currently sending `referrer` with path for image/bookmark
requests. We shouldn't do that as it exposes the rooms to other servers.

## `<img>`
- `<img>` tags have the right referrerpolicy to be
`strict-origin-when-cross-origin`:
https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#referrerpolicy
- _however_, because we use React, it looks like react creates a raw DOM
node and adds properties one by one and it loses the default
referrerpolicy it would otherwise get! So, in `BookmarkShapeUtil` we
explicitly state the `referrerpolicy`
- `background-image` does the right thing 👍 
- _also_, I added this to places we do programmatic `new Image()`

## `fetch`
- _however_, fetch does not! wtf.
https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch

it's almost a footnote in this section of the docs
(https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#supplying_request_options)
that `no-referrer-when-downgrade` is the default.

## `new Image()`
ugh, but _also_ doing a programmatic `new Image()` doesn't do the right
thing and we need to set the referrerpolicy here as well

### Change Type

<!--  Please select a 'Scope' label ️ -->

- [x] `sdk` — Changes the tldraw SDK
- [x] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [x] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [ ] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know


### Test Plan

1. Test on staging that referrer with path isn't being sent anymore.

### Release Notes

- Security: fix referrer being sent for bookmarks and images.
This commit is contained in:
Mime Čuvalo 2024-06-05 11:52:10 +01:00 committed by GitHub
parent b04ded47c3
commit b7bc2dbbce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 46 additions and 11 deletions

View file

@ -5,6 +5,7 @@
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<meta name="referrer" content="strict-origin-when-cross-origin" />
<meta name="theme-color" content="#FFFFFF" data-rh="true" /> <meta name="theme-color" content="#FFFFFF" data-rh="true" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />

View file

@ -17,6 +17,7 @@ function preloadIcon(url: string) {
;(image as any).fetchPriority = 'low' ;(image as any).fetchPriority = 'low'
image.onload = resolve image.onload = resolve
image.onerror = reject image.onerror = reject
image.referrerPolicy = 'strict-origin-when-cross-origin'
image.src = url image.src = url
}) })
} }

View file

@ -18,6 +18,7 @@ export async function createAssetFromFile({ file }: { type: 'file'; file: File }
await fetch(url, { await fetch(url, {
method: 'POST', method: 'POST',
body: file, body: file,
referrerPolicy: 'strict-origin-when-cross-origin',
}) })
const assetId: TLAssetId = AssetRecordType.createId(getHashForString(url)) const assetId: TLAssetId = AssetRecordType.createId(getHashForString(url))

View file

@ -40,7 +40,11 @@ export async function createAssetFromUrl({ url }: { type: 'url'; url: string }):
let meta: { image: string; title: string; description: string } let meta: { image: string; title: string; description: string }
try { try {
const resp = await fetch(url, { method: 'GET', mode: 'no-cors' }) const resp = await fetch(url, {
method: 'GET',
mode: 'no-cors',
referrerPolicy: 'strict-origin-when-cross-origin',
})
const html = await resp.text() const html = await resp.text()
const doc = new DOMParser().parseFromString(html, 'text/html') const doc = new DOMParser().parseFromString(html, 'text/html')
meta = { meta = {

View file

@ -30,7 +30,11 @@ export async function onCreateAssetFromUrl({
let meta: { image: string; title: string; description: string } let meta: { image: string; title: string; description: string }
try { try {
const resp = await fetch(url, { method: 'GET', mode: 'no-cors' }) const resp = await fetch(url, {
method: 'GET',
mode: 'no-cors',
referrerPolicy: 'strict-origin-when-cross-origin',
})
const html = await resp.text() const html = await resp.text()
const doc = new DOMParser().parseFromString(html, 'text/html') const doc = new DOMParser().parseFromString(html, 'text/html')
meta = { meta = {

View file

@ -113,7 +113,11 @@ export function registerDefaultExternalContentHandlers(
let meta: { image: string; title: string; description: string } let meta: { image: string; title: string; description: string }
try { try {
const resp = await fetch(url, { method: 'GET', mode: 'no-cors' }) const resp = await fetch(url, {
method: 'GET',
mode: 'no-cors',
referrerPolicy: 'strict-origin-when-cross-origin',
})
const html = await resp.text() const html = await resp.text()
const doc = new DOMParser().parseFromString(html, 'text/html') const doc = new DOMParser().parseFromString(html, 'text/html')
meta = { meta = {

View file

@ -61,6 +61,7 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
<img <img
className="tl-bookmark__image" className="tl-bookmark__image"
draggable={false} draggable={false}
referrerPolicy="strict-origin-when-cross-origin"
src={asset?.props.image} src={asset?.props.image}
alt={asset?.props.title || ''} alt={asset?.props.title || ''}
/> />

View file

@ -19,7 +19,7 @@ import { HyperlinkButton } from '../shared/HyperlinkButton'
import { usePrefersReducedMotion } from '../shared/usePrefersReducedMotion' import { usePrefersReducedMotion } from '../shared/usePrefersReducedMotion'
async function getDataURIFromURL(url: string): Promise<string> { async function getDataURIFromURL(url: string): Promise<string> {
const response = await fetch(url) const response = await fetch(url, { referrerPolicy: 'strict-origin-when-cross-origin' })
const blob = await response.blob() const blob = await response.blob()
return FileHelpers.blobToDataUrl(blob) return FileHelpers.blobToDataUrl(blob)
} }
@ -85,6 +85,7 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
setStaticFrameSrc(canvas.toDataURL()) setStaticFrameSrc(canvas.toDataURL())
} }
image.crossOrigin = 'anonymous' image.crossOrigin = 'anonymous'
image.referrerPolicy = 'strict-origin-when-cross-origin'
image.src = url image.src = url
return () => { return () => {

View file

@ -28,7 +28,9 @@ export function getFontDefForExport(fontStyle: TLDefaultFontStyle): SvgExportDef
const fontFaceRule: string = (font as any).$$_fontface const fontFaceRule: string = (font as any).$$_fontface
if (!url || !fontFaceRule) return null if (!url || !fontFaceRule) return null
const fontFile = await (await fetch(url)).blob() const fontFile = await (
await fetch(url, { referrerPolicy: 'strict-origin-when-cross-origin' })
).blob()
const base64FontFile = await FileHelpers.blobToDataUrl(fontFile) const base64FontFile = await FileHelpers.blobToDataUrl(fontFile)
const newFontFaceRule = fontFaceRule.replace(url, base64FontFile) const newFontFaceRule = fontFaceRule.replace(url, base64FontFile)

View file

@ -17,11 +17,13 @@ export function AssetUrlsProvider({
useEffect(() => { useEffect(() => {
for (const src of Object.values(assetUrls.icons)) { for (const src of Object.values(assetUrls.icons)) {
const image = new Image() const image = new Image()
image.referrerPolicy = 'strict-origin-when-cross-origin'
image.src = src image.src = src
image.decode() image.decode()
} }
for (const src of Object.values(assetUrls.embedIcons)) { for (const src of Object.values(assetUrls.embedIcons)) {
const image = new Image() const image = new Image()
image.referrerPolicy = 'strict-origin-when-cross-origin'
image.src = src image.src = src
image.decode() image.decode()
} }

View file

@ -14,7 +14,12 @@ export async function pasteFiles(
point?: VecLike, point?: VecLike,
sources?: TLExternalContentSource[] sources?: TLExternalContentSource[]
) { ) {
const blobs = await Promise.all(urls.map(async (url) => await (await fetch(url)).blob())) const blobs = await Promise.all(
urls.map(
async (url) =>
await (await fetch(url, { referrerPolicy: 'strict-origin-when-cross-origin' })).blob()
)
)
const files = blobs.map((blob) => new File([blob], 'tldrawFile', { type: blob.type })) const files = blobs.map((blob) => new File([blob], 'tldrawFile', { type: blob.type }))
editor.mark('paste') editor.mark('paste')

View file

@ -20,7 +20,10 @@ export async function pasteUrl(
try { try {
// skip this step if the url doesn't contain an image extension, treat it as a regular bookmark // skip this step if the url doesn't contain an image extension, treat it as a regular bookmark
if (new URL(url).pathname.match(/\.(png|jpe?g|gif|svg|webp)$/i)) { if (new URL(url).pathname.match(/\.(png|jpe?g|gif|svg|webp)$/i)) {
const resp = await fetch(url, { method: 'HEAD' }) const resp = await fetch(url, {
method: 'HEAD',
referrerPolicy: 'strict-origin-when-cross-origin',
})
if (resp.headers.get('content-type')?.match(/^image\//)) { if (resp.headers.get('content-type')?.match(/^image\//)) {
editor.mark('paste') editor.mark('paste')
pasteFiles(editor, [url]) pasteFiles(editor, [url])

View file

@ -65,6 +65,7 @@ export async function getSvgAsImage(
resolve(null) resolve(null)
} }
image.referrerPolicy = 'strict-origin-when-cross-origin'
image.src = svgUrl image.src = svgUrl
}) })

View file

@ -188,7 +188,9 @@ export async function serializeTldrawJson(store: TLStore): Promise<string> {
try { try {
// try to save the asset as a base64 string // try to save the asset as a base64 string
assetSrcToSave = await FileHelpers.blobToDataUrl( assetSrcToSave = await FileHelpers.blobToDataUrl(
await (await fetch(record.props.src)).blob() await (
await fetch(record.props.src, { referrerPolicy: 'strict-origin-when-cross-origin' })
).blob()
) )
} catch { } catch {
// if that fails, just save the original src // if that fails, just save the original src

View file

@ -10,9 +10,11 @@ export class FileHelpers {
* from https://stackoverflow.com/a/53817185 * from https://stackoverflow.com/a/53817185
*/ */
static async dataUrlToArrayBuffer(dataURL: string) { static async dataUrlToArrayBuffer(dataURL: string) {
return fetch(dataURL).then(function (result) { return fetch(dataURL, { referrerPolicy: 'strict-origin-when-cross-origin' }).then(
return result.arrayBuffer() function (result) {
}) return result.arrayBuffer()
}
)
} }
/** /**

View file

@ -72,6 +72,7 @@ export class MediaHelpers {
reject(new Error('Could not load image')) reject(new Error('Could not load image'))
} }
img.crossOrigin = 'anonymous' img.crossOrigin = 'anonymous'
img.referrerPolicy = 'strict-origin-when-cross-origin'
img.src = src img.src = src
}) })
} }