diff --git a/content/docs/reference/media/repo-based.mdx b/content/docs/reference/media/repo-based.mdx index 2544fecc..c1eff06b 100644 --- a/content/docs/reference/media/repo-based.mdx +++ b/content/docs/reference/media/repo-based.mdx @@ -48,6 +48,13 @@ Set this to `""`, if you want your media to be uploaded to the root of your `pub > Anything in this directory will be synced with TinaCloud's media server, and the images will be publicly accessible. + + Uploaded files are served from your own domain. See [Serving Uploaded Media Safely](/docs/reference/media/serving-media-safely) for the headers that keep an opened file from running as a page. + } +/> + ### Media Permissions The `static` property determines whether media files can be uploaded, edited, or deleted directly through the editor. diff --git a/content/docs/reference/media/serving-media-safely.mdx b/content/docs/reference/media/serving-media-safely.mdx new file mode 100644 index 00000000..58205112 --- /dev/null +++ b/content/docs/reference/media/serving-media-safely.mdx @@ -0,0 +1,99 @@ +--- +title: Serving Uploaded Media Safely +last_edited: '2026-10-10T00:00:00.000Z' +--- + +[Repo-based media](/docs/reference/media/repo-based) is committed to your repository and served from your site's `public` folder, so every uploaded file comes from your site's own domain. If a browser runs one of those files as a page or a script, it runs with your site's cookies and storage. + +As of `@tinacms/cli` 4.0.1, TinaCMS's upload route rejects HTML, XML and script uploads. Not every upload path runs that check. SVG stays allowed, because logos and icons rely on it, and an SVG can still run script when someone opens its URL directly. The settings below close that gap and protect any file that reaches your repository another way. + +## 1. Stop browsers guessing the type + +Send `X-Content-Type-Options: nosniff` for your media path. The browser then trusts the content type your server declares instead of guessing one from the file's bytes. + +## 2. Stop an uploaded file running as a page + +Send a Content Security Policy on the media path that allows no script and sandboxes the file: + +```text +Content-Security-Policy: default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'; sandbox +``` + +This applies only when someone opens a file directly. Images and SVGs placed with `` on your own pages are unaffected, because a CSP header governs a document, not an image loaded into one. + + + `default-src 'none'` can stop PDFs opening inline in some browsers, because it also blocks the built-in viewer. Check the browsers you support, and don't send this Content Security Policy for `.pdf` if they need it. + } +/> + +## 3. Serve media from its own domain where you can + +A separate domain shares no cookies or storage with your site, so nothing uploaded there can act as your site. External stores (S3, Cloudinary, DigitalOcean Spaces, Azure) give you this when you serve from the provider's own hostname or a dedicated media subdomain. Avoid putting a bucket behind a path on your main domain. See [External Media Providers](/docs/reference/media/external/authentication) for setup. + +## Examples + +Replace `/uploads` with your [`mediaRoot`](/docs/reference/media/repo-based#media-storage-path). Each example sets `X-Content-Type-Options` and the Content Security Policy on the media path. + +**Next.js** (`next.config.js`): + +```js +module.exports = { + async headers() { + return [ + { + source: '/uploads/:path*', + headers: [ + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { + key: 'Content-Security-Policy', + value: + "default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'; sandbox", + }, + ], + }, + ]; + }, +}; +``` + +**Netlify** (`_headers` in your publish directory, e.g. `public/_headers`): + +```text +/uploads/* + X-Content-Type-Options: nosniff + Content-Security-Policy: default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'; sandbox +``` + +**Vercel** (`vercel.json`): + +```json +{ + "headers": [ + { + "source": "/uploads/(.*)", + "headers": [ + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { + "key": "Content-Security-Policy", + "value": "default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'; sandbox" + } + ] + } + ] +} +``` + +**nginx** (an `add_header` in a `location` replaces the ones inherited from `server`, so repeat headers like `Strict-Transport-Security` here): + +```nginx +location /uploads/ { + add_header X-Content-Type-Options "nosniff" always; + add_header Content-Security-Policy "default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'; sandbox" always; +} +``` + +## Who can upload + +Anyone with upload access can put a file on your domain. Give media access only to editors you trust, which matters most on self-hosted setups with several editors. diff --git a/content/navigation-bar/docs-navigation-bar.json b/content/navigation-bar/docs-navigation-bar.json index 997ca85f..4e2e5ec9 100644 --- a/content/navigation-bar/docs-navigation-bar.json +++ b/content/navigation-bar/docs-navigation-bar.json @@ -317,6 +317,11 @@ "slug": "content/docs/reference/media/repo-based.mdx", "_template": "item" }, + { + "slug": "content/docs/reference/media/serving-media-safely.mdx", + "title": "Serving Uploaded Media Safely", + "_template": "item" + }, { "slug": "content/docs/reference/media/overriding-accepted-media-types.mdx", "title": "Overriding Default Media Types",