Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add image config for dangerouslyAllowSVG and contentSecurityPolicy #34431

Merged
merged 12 commits into from Feb 16, 2022
18 changes: 17 additions & 1 deletion docs/api-reference/next/image.md
Expand Up @@ -16,7 +16,8 @@ description: Enable Image Optimization with the built-in Image component.

| Version | Changes |
| --------- | ------------------------------------------------------------------------------------------------- |
| `v12.0.9` | `lazyRoot` prop added |
| `v12.1.0` | `dangerouslyAllowSVG` and `contentSecurityPolicy` configuration added. |
| `v12.0.9` | `lazyRoot` prop added. |
| `v12.0.0` | `formats` configuration added.<br/>AVIF support added.<br/>Wrapper `<div>` changed to `<span>`. |
| `v11.1.0` | `onLoadingComplete` and `lazyBoundary` props added. |
| `v11.0.0` | `src` prop support for static import.<br/>`placeholder` prop added.<br/>`blurDataURL` prop added. |
Expand Down Expand Up @@ -439,6 +440,21 @@ module.exports = {
}
```

### Dangerously Allow SVG

The default [loader](#loader) does not optimize SVG images for a few reasons. First, SVG is a vector format meaning it can be resized losslessly. Second, SVG has many of the same features as HTML/CSS, which can lead to vulnerabilities without proper [Content Security Policy (CSP) headers](/docs/advanced-features/security-headers.md).

If you need to serve SVG images with the default Image Optimization API, you can set `dangerouslyAllowSVG` and `contentSecurityPolicy` inside your `next.config.js`:

```js
module.exports = {
images: {
dangerouslyAllowSVG: true,
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
},
}
```

## Related

For an overview of the Image component features and usage guidelines, see:
Expand Down
4 changes: 4 additions & 0 deletions errors/invalid-images-config.md
Expand Up @@ -27,6 +27,10 @@ module.exports = {
minimumCacheTTL: 60,
// ordered list of acceptable optimized image formats (mime types)
formats: ['image/webp'],
// enable dangerous use of SVG images
dangerouslyAllowSVG: false,
// set the Content-Security-Policy header
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
},
}
```
Expand Down
4 changes: 4 additions & 0 deletions packages/next/client/image.tsx
Expand Up @@ -385,6 +385,10 @@ export default function Image({
isLazy = false
}

if (src.endsWith('.svg') && !config.dangerouslyAllowSVG) {
unoptimized = true
}

if (process.env.NODE_ENV !== 'production') {
if (!src) {
throw new Error(
Expand Down
22 changes: 22 additions & 0 deletions packages/next/server/config.ts
Expand Up @@ -351,6 +351,28 @@ function assignDefaults(userConfig: { [key: string]: any }) {
)
}
}

if (
typeof images.dangerouslyAllowSVG !== 'undefined' &&
typeof images.dangerouslyAllowSVG !== 'boolean'
) {
throw new Error(
`Specified images.dangerouslyAllowSVG should be a boolean
', '
)}), received (${images.dangerouslyAllowSVG}).\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config`
)
}

if (
typeof images.contentSecurityPolicy !== 'undefined' &&
typeof images.contentSecurityPolicy !== 'string'
) {
throw new Error(
`Specified images.contentSecurityPolicy should be a string
', '
)}), received (${images.contentSecurityPolicy}).\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config`
)
}
}

if (result.webpack5 === false) {
Expand Down
16 changes: 12 additions & 4 deletions packages/next/server/image-config.ts
Expand Up @@ -29,16 +29,22 @@ export type ImageConfigComplete = {
path: string

/** @see [Image domains configuration](https://nextjs.org/docs/basic-features/image-optimization#domains) */
domains?: string[]
domains: string[]

/** @see [Cache behavior](https://nextjs.org/docs/api-reference/next/image#caching-behavior) */
disableStaticImages?: boolean
disableStaticImages: boolean

/** @see [Cache behavior](https://nextjs.org/docs/api-reference/next/image#caching-behavior) */
minimumCacheTTL?: number
minimumCacheTTL: number

/** @see [Acceptable formats](https://nextjs.org/docs/api-reference/next/image#acceptable-formats) */
formats?: ImageFormat[]
formats: ImageFormat[]

/** @see [Dangerously Allow SVG](https://nextjs.org/docs/api-reference/next/image#dangerously-allow-svg) */
dangerouslyAllowSVG: boolean

/** @see [Dangerously Allow SVG](https://nextjs.org/docs/api-reference/next/image#dangerously-allow-svg) */
contentSecurityPolicy: string
}

export type ImageConfig = Partial<ImageConfigComplete>
Expand All @@ -52,4 +58,6 @@ export const imageConfigDefault: ImageConfigComplete = {
disableStaticImages: false,
minimumCacheTTL: 60,
formats: ['image/webp'],
dangerouslyAllowSVG: false,
contentSecurityPolicy: `script-src 'none'; frame-src 'none'; sandbox;`,
}
25 changes: 20 additions & 5 deletions packages/next/server/image-optimizer.ts
Expand Up @@ -380,6 +380,16 @@ export async function imageOptimizer(
}
}

if (upstreamType === SVG && !nextConfig.images.dangerouslyAllowSVG) {
console.error(
`The requested resource "${href}" has type "${upstreamType}" but dangerouslyAllowSVG is disabled`
)
throw new ImageError(
400,
'"url" parameter is valid but image type is not allowed'
)
}

if (upstreamType) {
const vector = VECTOR_TYPES.includes(upstreamType)
const animate =
Expand Down Expand Up @@ -576,14 +586,15 @@ function getFileNameWithExtension(
return `${fileName}.${extension}`
}

export function setResponseHeaders(
function setResponseHeaders(
req: IncomingMessage,
res: ServerResponse,
url: string,
etag: string,
contentType: string | null,
isStatic: boolean,
xCache: XCacheHeader
xCache: XCacheHeader,
contentSecurityPolicy: string
) {
res.setHeader('Vary', 'Accept')
res.setHeader(
Expand All @@ -608,7 +619,9 @@ export function setResponseHeaders(
)
}

res.setHeader('Content-Security-Policy', `script-src 'none'; sandbox;`)
if (contentSecurityPolicy) {
res.setHeader('Content-Security-Policy', contentSecurityPolicy)
}
res.setHeader('X-Nextjs-Cache', xCache)

return { finished: false }
Expand All @@ -621,7 +634,8 @@ export function sendResponse(
extension: string,
buffer: Buffer,
isStatic: boolean,
xCache: XCacheHeader
xCache: XCacheHeader,
contentSecurityPolicy: string
) {
const contentType = getContentType(extension)
const etag = getHash([buffer])
Expand All @@ -632,7 +646,8 @@ export function sendResponse(
etag,
contentType,
isStatic,
xCache
xCache,
contentSecurityPolicy
)
if (!result.finished) {
res.end(buffer)
Expand Down
3 changes: 2 additions & 1 deletion packages/next/server/next-server.ts
Expand Up @@ -255,7 +255,8 @@ export default class NextNodeServer extends BaseServer {
cacheEntry.value.extension,
cacheEntry.value.buffer,
paramsResult.isStatic,
cacheEntry.isMiss ? 'MISS' : cacheEntry.isStale ? 'STALE' : 'HIT'
cacheEntry.isMiss ? 'MISS' : cacheEntry.isStale ? 'STALE' : 'HIT',
imagesConfig.contentSecurityPolicy
)
} catch (err) {
if (err instanceof ImageError) {
Expand Down
6 changes: 3 additions & 3 deletions test/integration/image-component/default/test/index.test.js
Expand Up @@ -208,7 +208,7 @@ function runTests(mode) {
)
await check(
() => browser.eval(`document.getElementById("img3").currentSrc`),
/test(.*)svg/
/test\.svg/
)
await check(
() => browser.eval(`document.getElementById("img4").currentSrc`),
Expand All @@ -224,7 +224,7 @@ function runTests(mode) {
)
await check(
() => browser.eval(`document.getElementById("msg3").textContent`),
'loaded 1 img3 with dimensions 266x266'
'loaded 1 img3 with dimensions 400x400'
)
await check(
() => browser.eval(`document.getElementById("msg4").textContent`),
Expand Down Expand Up @@ -1077,7 +1077,7 @@ function runTests(mode) {
expect(
await hasImageMatchingUrl(
browser,
`http://localhost:${appPort}/_next/image?url=%2Ftest.svg&w=828&q=75`
`http://localhost:${appPort}/test.svg`
)
).toBe(true)
expect(
Expand Down
53 changes: 53 additions & 0 deletions test/integration/image-optimizer/test/index.test.js
Expand Up @@ -224,6 +224,56 @@ describe('Image Optimizer', () => {
`Specified images.loader property (imgix) also requires images.path property to be assigned to a URL prefix.`
)
})

it('should error when images.dangerouslyAllowSVG is not a boolean', async () => {
await nextConfig.replace(
'{ /* replaceme */ }',
JSON.stringify({
images: {
dangerouslyAllowSVG: 'foo',
},
})
)
let stderr = ''

app = await launchApp(appDir, await findPort(), {
onStderr(msg) {
stderr += msg || ''
},
})
await waitFor(1000)
await killApp(app).catch(() => {})
await nextConfig.restore()

expect(stderr).toContain(
`Specified images.dangerouslyAllowSVG should be a boolean`
)
})

it('should error when images.contentSecurityPolicy is not a string', async () => {
await nextConfig.replace(
'{ /* replaceme */ }',
JSON.stringify({
images: {
contentSecurityPolicy: 1,
},
})
)
let stderr = ''

app = await launchApp(appDir, await findPort(), {
onStderr(msg) {
stderr += msg || ''
},
})
await waitFor(1000)
await killApp(app).catch(() => {})
await nextConfig.restore()

expect(stderr).toContain(
`Specified images.contentSecurityPolicy should be a string`
)
})
})

// domains for testing
Expand All @@ -240,11 +290,13 @@ describe('Image Optimizer', () => {

describe('Server support for minimumCacheTTL in next.config.js', () => {
const size = 96 // defaults defined in server/config.ts
const dangerouslyAllowSVG = true
const ctx = {
w: size,
isDev: false,
domains,
minimumCacheTTL,
dangerouslyAllowSVG,
imagesDir,
appDir,
}
Expand All @@ -253,6 +305,7 @@ describe('Image Optimizer', () => {
images: {
domains,
minimumCacheTTL,
dangerouslyAllowSVG,
},
})
ctx.nextOutput = ''
Expand Down