Skip to content

Commit

Permalink
Add image config for dangerouslyAllowSVG and contentSecurityPolicy (
Browse files Browse the repository at this point in the history
#34431)

## Feature

- [x] Integration tests added
- [x] Documentation added
- [x] Errors have helpful link attached, see `contributing.md`



Co-authored-by: Lee Robinson <9113740+leerob@users.noreply.github.com>
  • Loading branch information
styfle and leerob committed Feb 16, 2022
1 parent 9639fe7 commit 8a55612
Show file tree
Hide file tree
Showing 12 changed files with 208 additions and 69 deletions.
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

0 comments on commit 8a55612

Please sign in to comment.