diff --git a/apps/backend/src/api/routes/public.controller.ts b/apps/backend/src/api/routes/public.controller.ts index 698f5c75..e86578af 100644 --- a/apps/backend/src/api/routes/public.controller.ts +++ b/apps/backend/src/api/routes/public.controller.ts @@ -27,6 +27,7 @@ import { Readable, pipeline } from 'stream'; import { promisify } from 'util'; import { OnlyURL } from '@gitroom/nestjs-libraries/dtos/webhooks/webhooks.dto'; import { isSafePublicHttpsUrl } from '@gitroom/nestjs-libraries/dtos/webhooks/webhook.url.validator'; +import { ssrfSafeDispatcher } from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher'; const pump = promisify(pipeline); @@ -191,6 +192,8 @@ export class PublicController { r = await fetch(currentUrl, { signal: ac.signal, redirect: 'manual', + // @ts-ignore — undici option, not in lib.dom fetch types + dispatcher: ssrfSafeDispatcher, }); if (r.status >= 300 && r.status < 400) { diff --git a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts index 2c9bf934..d2a74efe 100644 --- a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts +++ b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts @@ -33,8 +33,8 @@ import { VideoFunctionDto } from '@gitroom/nestjs-libraries/dtos/videos/video.fu import { UploadDto } from '@gitroom/nestjs-libraries/dtos/media/upload.dto'; import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service'; import { GetNotificationsDto } from '@gitroom/nestjs-libraries/dtos/notifications/get.notifications.dto'; -import axios from 'axios'; import { Readable } from 'stream'; +import { ssrfSafeDispatcher } from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher'; // eslint-disable-next-line @typescript-eslint/no-var-requires const { fromBuffer } = require('file-type'); @@ -96,11 +96,14 @@ export class PublicIntegrationsController { @Body() body: UploadDto ) { Sentry.metrics.count('public_api-request', 1); - const response = await axios.get(body.url, { - responseType: 'arraybuffer', + const response = await fetch(body.url, { + // @ts-ignore — undici option, not in lib.dom fetch types + dispatcher: ssrfSafeDispatcher, }); - - const buffer = Buffer.from(response.data); + if (!response.ok) { + throw new HttpException({ msg: 'Failed to fetch URL' }, 400); + } + const buffer = Buffer.from(await response.arrayBuffer()); const detected = await fromBuffer(buffer); if (!detected || !PUBLIC_API_ALLOWED_MIME.has(detected.mime)) { throw new HttpException({ msg: 'Unsupported file type.' }, 400); diff --git a/libraries/nestjs-libraries/src/dtos/webhooks/ssrf.safe.dispatcher.ts b/libraries/nestjs-libraries/src/dtos/webhooks/ssrf.safe.dispatcher.ts new file mode 100644 index 00000000..f72317f8 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/webhooks/ssrf.safe.dispatcher.ts @@ -0,0 +1,39 @@ +import { Agent } from 'undici'; +import dns from 'node:dns'; +import net from 'node:net'; +import { isBlockedIp } from './webhook.url.validator'; + +// Pins DNS resolution: every resolved IP is checked with `isBlockedIp` and +// the caller (undici) connects to that same set. Closes the TOCTOU window +// `isSafePublicHttpsUrl` alone leaves open (see GHSA-f7jj-p389-4w45). +export const ssrfSafeDispatcher = new Agent({ + connect: { + lookup(hostname, options, callback) { + if (net.isIP(hostname)) { + const family = net.isIP(hostname); + if (isBlockedIp(hostname)) { + return callback(new Error('Blocked IP'), '', 0); + } + return options && (options as any).all + ? callback(null, [{ address: hostname, family }] as any, family) + : callback(null, hostname, family); + } + + dns.lookup(hostname, options, (err, address: any, family: any) => { + if (err) return callback(err, '', 0); + if (Array.isArray(address)) { + for (const entry of address) { + if (isBlockedIp(entry.address)) { + return callback(new Error('Blocked IP'), '', 0); + } + } + return callback(null, address as any, 0); + } + if (isBlockedIp(address)) { + return callback(new Error('Blocked IP'), '', 0); + } + callback(null, address, family); + }); + }, + }, +}); diff --git a/libraries/nestjs-libraries/src/upload/cloudflare.storage.ts b/libraries/nestjs-libraries/src/upload/cloudflare.storage.ts index 1ada0ac7..032646de 100644 --- a/libraries/nestjs-libraries/src/upload/cloudflare.storage.ts +++ b/libraries/nestjs-libraries/src/upload/cloudflare.storage.ts @@ -6,6 +6,8 @@ import mime from 'mime-types'; import { getExtension } from 'mime'; import { IUploadProvider } from './upload.interface'; import axios from 'axios'; +import { isSafePublicHttpsUrl } from '@gitroom/nestjs-libraries/dtos/webhooks/webhook.url.validator'; +import { ssrfSafeDispatcher } from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher'; // eslint-disable-next-line @typescript-eslint/no-var-requires const { fromBuffer } = require('file-type'); @@ -75,7 +77,13 @@ class CloudflareStorage implements IUploadProvider { } async uploadSimple(path: string) { - const loadImage = await fetch(path); + if (!(await isSafePublicHttpsUrl(path))) { + throw new Error('Unsafe URL'); + } + const loadImage = await fetch(path, { + // @ts-ignore — undici option, not in lib.dom fetch types + dispatcher: ssrfSafeDispatcher, + }); const body = Buffer.from(await loadImage.arrayBuffer()); const detected = await fromBuffer(body); if (!detected || !ALLOWED_MIME_TYPES.has(detected.mime)) { diff --git a/libraries/nestjs-libraries/src/upload/local.storage.ts b/libraries/nestjs-libraries/src/upload/local.storage.ts index 90cc42c5..3c064f69 100644 --- a/libraries/nestjs-libraries/src/upload/local.storage.ts +++ b/libraries/nestjs-libraries/src/upload/local.storage.ts @@ -3,6 +3,8 @@ import { mkdirSync, unlink, writeFileSync } from 'fs'; // @ts-ignore import mime from 'mime'; import { extname } from 'path'; +import { isSafePublicHttpsUrl } from '@gitroom/nestjs-libraries/dtos/webhooks/webhook.url.validator'; +import { ssrfSafeDispatcher } from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher'; // eslint-disable-next-line @typescript-eslint/no-var-requires const { fromBuffer } = require('file-type'); @@ -24,7 +26,13 @@ export class LocalStorage implements IUploadProvider { constructor(private uploadDirectory: string) {} async uploadSimple(path: string) { - const loadImage = await fetch(path); + if (!(await isSafePublicHttpsUrl(path))) { + throw new Error('Unsafe URL'); + } + const loadImage = await fetch(path, { + // @ts-ignore — undici option, not in lib.dom fetch types + dispatcher: ssrfSafeDispatcher, + }); const contentType = loadImage?.headers?.get('content-type') || loadImage?.headers?.get('Content-Type');