feat: security fix

This commit is contained in:
Nevo David 2026-04-22 22:25:48 +07:00
parent e51cae1614
commit 071143dcb0
5 changed files with 68 additions and 7 deletions

View file

@ -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) {

View file

@ -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);

View file

@ -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);
});
},
},
});

View file

@ -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)) {

View file

@ -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');