From f44db693f8bc3e4566c69204d74bb89ce5895c66 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 5 Oct 2022 14:07:09 +0100 Subject: [PATCH] feat: add prefix filtering and loop detection for local images (#83) * feat: add prefix filtering for local images * fix: prevent infinite loop Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- example/netlify/functions/ipx.ts | 1 + package.json | 2 +- src/index.ts | 60 +++++++++++++++++++++++++++++--- 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/example/netlify/functions/ipx.ts b/example/netlify/functions/ipx.ts index ff10e2c..4d691e4 100644 --- a/example/netlify/functions/ipx.ts +++ b/example/netlify/functions/ipx.ts @@ -10,6 +10,7 @@ export const handler = createIPXHandler({ domains: [ 'www.netlify.com' ], + localPrefix: '/img/', basePath: '/.netlify/builders/ipx/', responseHeaders: { 'Strict-Transport-Security': 'max-age=31536000', diff --git a/package.json b/package.json index 18cba1e..aab5228 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "prepack": "yarn build", "lint": "yarn eslint --ext .ts,.js,.mjs src", "test": "ava", - "dev": "cd example && netlify dev" + "dev": "netlify dev" }, "dependencies": { "@netlify/functions": "^1.3.0", diff --git a/src/index.ts b/src/index.ts index cfcbd32..57fb956 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,14 +7,41 @@ import etag from 'etag' import { loadSourceImage } from './http' import { decodeBase64Params, doPatternsMatchUrl, RemotePattern } from './utils' export interface IPXHandlerOptions extends Partial { + /** + * Path to cache directory + * @default os.tmpdir() /ipx-cache + */ cacheDir?: string + /** + * Base path for IPX requests + * @default /_ipx/ + */ basePath?: string propsEncoding?: 'base64' | undefined + /** + * Bypass domain check for remote images + */ bypassDomainCheck?: boolean + /** + * Restrict local image access to a specific prefix + */ + localPrefix?: string + /** + * Patterns used to verify remote image URLs + */ remotePatterns?: RemotePattern[] + /** + * Add custom headers to response + */ responseHeaders?: Record } +const SUBREQUEST_HEADER = 'x-ipx-subrequest' + +const plainText = { + 'Content-Type': 'text/plain' +} + export function createIPXHandler ({ cacheDir = join(tmpdir(), 'ipx-cache'), basePath = '/_ipx/', @@ -22,13 +49,26 @@ export function createIPXHandler ({ bypassDomainCheck, remotePatterns, responseHeaders, + localPrefix, ...opts }: IPXHandlerOptions = {}) { const ipx = createIPX({ ...opts, dir: join(cacheDir, 'cache') }) if (!basePath.endsWith('/')) { basePath = `${basePath}/` } + if (localPrefix && !localPrefix.startsWith('/')) { + localPrefix = `/${localPrefix}` + } const handler: Handler = async (event, _context) => { + if (event.headers[SUBREQUEST_HEADER]) { + // eslint-disable-next-line no-console + console.error('Source image loop detected') + return { + statusCode: 400, + body: 'Source image loop detected', + headers: plainText + } + } let domains = (opts as IPXOptions).domains || [] const remoteURLPatterns = remotePatterns || [] const requestEtag = event.headers['if-none-match'] @@ -43,18 +83,28 @@ export function createIPXHandler ({ if (params.error) { return { statusCode: 400, - body: params.error + body: params.error, + headers: plainText } } id = params.id modifiers = params.modifiers } - const requestHeaders: Record = {} + const requestHeaders: Record = { + [SUBREQUEST_HEADER]: '1' + } const isLocal = !id.startsWith('http://') && !id.startsWith('https://') if (isLocal) { const url = new URL(event.rawUrl) url.pathname = id + if (localPrefix && !url.pathname.startsWith(localPrefix)) { + return { + statusCode: 400, + body: 'Invalid source image path', + headers: plainText + } + } id = url.toString() if (event.headers.cookie) { requestHeaders.cookie = event.headers.cookie @@ -70,7 +120,8 @@ export function createIPXHandler ({ if (!parsedUrl.host) { return { statusCode: 403, - body: 'Hostname is missing: ' + id + body: 'Hostname is missing: ' + id, + headers: plainText } } @@ -107,7 +158,8 @@ export function createIPXHandler ({ `) return { statusCode: 403, - body: 'URL not on allowlist: ' + id + body: 'URL not on allowlist: ' + id, + headers: plainText } } }