From 74bea45acab097b01b3938779436ed81adec1487 Mon Sep 17 00:00:00 2001 From: kulesy Date: Mon, 12 Oct 2026 07:14:29 +1100 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=93=9D=20Add=20deployment=20guidance?= =?UTF-8?q?=20for=20serving=20uploaded=20media=20safely?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- content/docs/reference/media/repo-based.mdx | 7 ++ .../reference/media/serving-media-safely.mdx | 99 +++++++++++++++++++ .../navigation-bar/docs-navigation-bar.json | 5 + 3 files changed, 111 insertions(+) create mode 100644 content/docs/reference/media/serving-media-safely.mdx 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..de31c369 --- /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 rejects HTML, XML and script uploads on the server. 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: + +``` +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. + + + `sandbox` can stop PDFs opening inline in some browsers. Check the browsers you support, and drop `sandbox` 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** (`public/_headers`): + +``` +/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**: + +```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", From 802485283439cf1f482b50059fa190980660d9b9 Mon Sep 17 00:00:00 2001 From: kulesy Date: Mon, 12 Oct 2026 07:33:59 +1100 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=93=9D=20Label=20the=20plaintext=20me?= =?UTF-8?q?dia-header=20code=20blocks=20as=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- content/docs/reference/media/serving-media-safely.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/reference/media/serving-media-safely.mdx b/content/docs/reference/media/serving-media-safely.mdx index de31c369..26e9806b 100644 --- a/content/docs/reference/media/serving-media-safely.mdx +++ b/content/docs/reference/media/serving-media-safely.mdx @@ -15,7 +15,7 @@ Send `X-Content-Type-Options: nosniff` for your media path. The browser then tru 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 ``` @@ -60,7 +60,7 @@ module.exports = { **Netlify** (`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 From 1ddaa267164747c100a75ff8f7d0c0fa5a7b6cd1 Mon Sep 17 00:00:00 2001 From: kulesy Date: Mon, 12 Oct 2026 09:54:29 +1100 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=93=9D=20Sharpen=20the=20media-header?= =?UTF-8?q?=20caveats=20from=20Josh's=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- content/docs/reference/media/serving-media-safely.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/content/docs/reference/media/serving-media-safely.mdx b/content/docs/reference/media/serving-media-safely.mdx index 26e9806b..c9c9cd2d 100644 --- a/content/docs/reference/media/serving-media-safely.mdx +++ b/content/docs/reference/media/serving-media-safely.mdx @@ -24,7 +24,7 @@ This applies only when someone opens a file directly. Images and SVGs placed wit - `sandbox` can stop PDFs opening inline in some browsers. Check the browsers you support, and drop `sandbox` for `.pdf` if they need it. + `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. } /> @@ -58,7 +58,7 @@ module.exports = { }; ``` -**Netlify** (`public/_headers`): +**Netlify** (`_headers` in your publish directory, e.g. `public/_headers`): ```text /uploads/* @@ -85,7 +85,7 @@ module.exports = { } ``` -**nginx**: +**nginx** (an `add_header` in a `location` replaces the ones inherited from `server`, so repeat headers like `Strict-Transport-Security` here): ```nginx location /uploads/ { From d9996b343cc59cc3efe73abe1aaf8ec6dca86d74 Mon Sep 17 00:00:00 2001 From: kulesy Date: Mon, 12 Oct 2026 11:00:20 +1100 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=93=9D=20Scope=20the=20upload-check?= =?UTF-8?q?=20line=20to=20TinaCMS's=20own=20upload=20route?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- content/docs/reference/media/serving-media-safely.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/reference/media/serving-media-safely.mdx b/content/docs/reference/media/serving-media-safely.mdx index c9c9cd2d..604467fa 100644 --- a/content/docs/reference/media/serving-media-safely.mdx +++ b/content/docs/reference/media/serving-media-safely.mdx @@ -5,7 +5,7 @@ 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 rejects HTML, XML and script uploads on the server. 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. +As of `@tinacms/cli` 4.0.1, TinaCMS's upload route rejects HTML, XML and script uploads. Not every upload path runs that check, and SVG stays allowed because logos and icons rely on it and 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 From 00f606b6d78148ecc8e57c00cccd73803b2a9e8f Mon Sep 17 00:00:00 2001 From: "Eli Kent [SSW]" <69125238+kulesy@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:15:24 +1100 Subject: [PATCH 5/5] Update content/docs/reference/media/serving-media-safely.mdx Co-authored-by: Josh Berman [SSW] <137844305+joshbermanssw@users.noreply.github.com> --- content/docs/reference/media/serving-media-safely.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/reference/media/serving-media-safely.mdx b/content/docs/reference/media/serving-media-safely.mdx index 604467fa..58205112 100644 --- a/content/docs/reference/media/serving-media-safely.mdx +++ b/content/docs/reference/media/serving-media-safely.mdx @@ -5,7 +5,7 @@ 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, and SVG stays allowed because logos and icons rely on it and 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. +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