Security context
High· 7.5GHSA-9gr3-7897-pp7m CVE-2021-39178CWE-79Published Sep 1, 2021

XSS in Image Optimization API for Next.js

Research this vulnerability

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

10.0.0 → fixed in 11.1.1

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

Steven· Aug 30, 2021, 01:45 PM+58145b95140ece
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 : 39
expect(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)

Steven· Aug 30, 2021, 04:51 PM+4517afc97c574
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 : 39
expect(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