XSS in Image Optimization API for Next.js
Research is free — Hunters explains how the bug works, the root-cause code pattern, how the fix addresses it, and how to test whether a target is affected, in chat. Investigate & write exploit is a paid run — the engine reads the advisory and fix commits, then builds and validates a working proof-of-concept exploit with reproduction steps.
Affected versions
Details
### Impact - **Affected:** All of the following must be true to be affected - Next.js between version 10.0.0 and 11.1.0 - The `next.config.js` file has [`images.domains`](https://nextjs.org/docs/basic-features/image-optimization#domains) array assigned - The image host assigned in [`images.domains`](https://nextjs.org/docs/basic-features/image-optimization#domains) allows user-provided SVG - **Not affected**: The `next.config.js` file has [`images.loader`](https://nextjs.org/docs/basic-features/image-optimization#loader) assigned to something other than default - **Not affected**: Deployments on [Vercel](https://vercel.com) are not affected ### Patches [Next.js v11.1.1](https://github.com/vercel/next.js/releases/tag/v11.1.1)
The fix
Add CSP to Image Optimization API
packages/next/server/image-optimizer.ts+2 −0
@@ -525,6 +525,8 @@ function setResponseHeaders(res.setHeader('Content-Disposition', `inline; filename="${fileName}"`)}+res.setHeader('Content-Security-Policy', `script-src 'none'; sandbox;`)+return { finished: false }}
test/integration/production/pages/svg-image.js+14 −0
@@ -0,0 +1,14 @@+import React from 'react'+import Image from 'next/image'++const Page = () => {+return (+<div>+<h1>SVG with a script tag attempting XSS</h1>+<Image id="img" src="/xss.svg" width="100" height="100" />+<p id="msg">safe</p>+</div>+)+}++export default Page
test/integration/production/public/xss.svg+9 −0
@@ -0,0 +1,9 @@+<html xmlns="http://www.w3.org/1999/xhtml">+<head>+<title>XSS</title>+</head>+<body>+<p id="msg">safe</p>+<script>document.getElementById('msg').textContent='hacked'</script>+</body>+</html>
test/integration/production/test/security.js+19 −0
@@ -342,5 +342,24 @@ module.exports = (context) => {expect(pathname).toBe('/%2fexample.com')expect(hostname).not.toBe('example.com')})++it('should not execute script embedded inside svg image', async () => {+let browser+try {+browser = await webdriver(context.appPort, '/svg-image')+await browser.eval(`document.getElementById("img").scrollIntoView()`)+expect(await browser.elementById('img').getAttribute('src')).toContain(+'xss.svg'+)+expect(await browser.elementById('msg').text()).toBe('safe')+browser = await webdriver(+context.appPort,+'/_next/image?url=%2Fxss.svg&w=256&q=75'+)+expect(await browser.elementById('msg').text()).toBe('safe')+} finally {+if (browser) await browser.close()+}+})})}test/integration/production/test/index.test.js | 15 +++++++--------1 file changed, 7 insertions(+), 8 deletions(-)
test/integration/production/test/index.test.js+7 −8
@@ -78,7 +78,7 @@ describe('Production Usage', () => {})it('should contain generated page count in output', async () => {-const pageCount = process.env.NEXT_PRIVATE_TEST_WEBPACK4_MODE ? 37 : 38+const pageCount = process.env.NEXT_PRIVATE_TEST_WEBPACK4_MODE ? 38 : 39expect(output).toContain(`Generating static pages (0/${pageCount})`)expect(output).toContain(`Generating static pages (${pageCount}/${pageCount})`@@ -469,13 +469,12 @@ describe('Production Usage', () => {it('should set title by routeChangeComplete event', async () => {const browser = await webdriver(appPort, '/')await browser.eval(function setup() {-window.next.router.events.on(-'routeChangeComplete',-function handler(url) {-window.routeChangeTitle = document.title-window.routeChangeUrl = url-}-)+window.next.router.events.on('routeChangeComplete', function handler(+url+) {+window.routeChangeTitle = document.title+window.routeChangeUrl = url+})window.next.router.push('/with-title')})await browser.waitForElementByCss('#with-title')test/integration/production/test/index.test.js | 13 +++++++------1 file changed, 7 insertions(+), 6 deletions(-)
test/integration/production/test/index.test.js+7 −6
@@ -469,12 +469,13 @@ describe('Production Usage', () => {it('should set title by routeChangeComplete event', async () => {const browser = await webdriver(appPort, '/')await browser.eval(function setup() {-window.next.router.events.on('routeChangeComplete', function handler(-url-) {-window.routeChangeTitle = document.title-window.routeChangeUrl = url-})+window.next.router.events.on(+'routeChangeComplete',+function handler(url) {+window.routeChangeTitle = document.title+window.routeChangeUrl = url+}+)window.next.router.push('/with-title')})await browser.waitForElementByCss('#with-title')
Add CSP to Image Optimization API (#28620)
packages/next/server/image-optimizer.ts+2 −0
@@ -525,6 +525,8 @@ function setResponseHeaders(res.setHeader('Content-Disposition', `inline; filename="${fileName}"`)}+res.setHeader('Content-Security-Policy', `script-src 'none'; sandbox;`)+return { finished: false }}
test/integration/production/pages/svg-image.js+14 −0
@@ -0,0 +1,14 @@+import React from 'react'+import Image from 'next/image'++const Page = () => {+return (+<div>+<h1>SVG with a script tag attempting XSS</h1>+<Image id="img" src="/xss.svg" width="100" height="100" />+<p id="msg">safe</p>+</div>+)+}++export default Page
test/integration/production/public/xss.svg+9 −0
@@ -0,0 +1,9 @@+<html xmlns="http://www.w3.org/1999/xhtml">+<head>+<title>XSS</title>+</head>+<body>+<p id="msg">safe</p>+<script>document.getElementById('msg').textContent='hacked'</script>+</body>+</html>
test/integration/production/test/index.test.js+1 −1
@@ -78,7 +78,7 @@ describe('Production Usage', () => {})it('should contain generated page count in output', async () => {-const pageCount = process.env.NEXT_PRIVATE_TEST_WEBPACK4_MODE ? 37 : 38+const pageCount = process.env.NEXT_PRIVATE_TEST_WEBPACK4_MODE ? 38 : 39expect(output).toContain(`Generating static pages (0/${pageCount})`)expect(output).toContain(`Generating static pages (${pageCount}/${pageCount})`
test/integration/production/test/security.js+19 −0
@@ -342,5 +342,24 @@ module.exports = (context) => {expect(pathname).toBe('/%2fexample.com')expect(hostname).not.toBe('example.com')})++it('should not execute script embedded inside svg image', async () => {+let browser+try {+browser = await webdriver(context.appPort, '/svg-image')+await browser.eval(`document.getElementById("img").scrollIntoView()`)+expect(await browser.elementById('img').getAttribute('src')).toContain(+'xss.svg'+)+expect(await browser.elementById('msg').text()).toBe('safe')+browser = await webdriver(+context.appPort,+'/_next/image?url=%2Fxss.svg&w=256&q=75'+)+expect(await browser.elementById('msg').text()).toBe('safe')+} finally {+if (browser) await browser.close()+}+})})}
References
- WEBhttps://github.com/vercel/next.js/security/advisories/GHSA-9gr3-7897-pp7m
- ADVISORYhttps://nvd.nist.gov/vuln/detail/CVE-2021-39178
- WEBhttps://github.com/vercel/next.js/pull/28620
- WEBhttps://github.com/vercel/next.js/commit/7afc97c5744b38bdf36aa7f87625f438224688aa
- PACKAGEhttps://github.com/vercel/next.js
- WEBhttps://github.com/vercel/next.js/releases/tag/v11.1.1