Sync-in Server: SSRF protection bypass via IPv4-mapped IPv6 addresses in regExpPrivateIP
Description
Summary: The private IP blocklist regex used in the URL download feature does not match IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1), allowing SSRF protection to be bypassed on dual-stack systems.
Affected components
backend/src/applications/files/services/files-manager.service.ts – downloadFromUrl() checks regExpPrivateIP against request.socket.remoteAddress. backend/src/applications/files/utils/url-file.ts – regExpPrivateIP does not include ::ffff: variants.
Details: The regExpPrivateIP regex in backend/src/applications/files/utils/url-file.ts correctly blocks standard IPv4 private ranges but does not include ::ffff: prefixed variants. On dual-stack systems, Node.js can report a socket's remoteAddress in IPv4-mapped IPv6 form, meaning the check in FilesManager.downloadFromUrl() can be bypassed entirely.
PoC: poc.pdf
Proof:
Impact: An attacker can supply a crafted URL pointing to an internal address that gets reported as ::ffff:127.0.0.1 or ::ffff:10.x.x.x, causing the server to fetch internal resources that should be blocked. Any user with access to the file download feature is a potential attacker.
Affected products
1Patches
422e773e5b826fix(backend:files): harden remote downloads against SSRF, redirects, proxy bypasses and oversized streams
14 files changed · +518 −130
backend/package.json+1 −0 modified@@ -63,6 +63,7 @@ "fast-xml-parser": "^5.0.7", "fs-extra": "^11.2.0", "html-to-text": "^10.0.0", + "ipaddr.js": "^2.4.0", "js-yaml": "^4.1.0", "ldapts": "^8.0.36", "mime-types": "^3.0.1",
backend/src/applications/files/services/files-manager.service.spec.ts+34 −4 modified@@ -2,6 +2,7 @@ import { HttpService } from '@nestjs/axios' import { HttpStatus } from '@nestjs/common' import { Test, TestingModule } from '@nestjs/testing' import archiver from 'archiver' +import { lookup } from 'node:dns/promises' import fs from 'node:fs' import path from 'node:path' import { PassThrough, Readable } from 'node:stream' @@ -20,6 +21,7 @@ import { DownloadFileDto } from '../dto/file-operations.dto' import { FileEvent, FileTaskEvent } from '../events/file-events' import { FileError } from '../models/file-error' import { LockConflict } from '../models/file-lock-error' +import { FILE_ERROR_MESSAGES } from '../utils/errors' import { SendFile } from '../utils/send-file' import * as unzipUtils from '../utils/unzip-file' import * as filesUtils from '../utils/files' @@ -36,10 +38,14 @@ jest.mock('tar', () => ({ __esModule: true, extract: jest.fn() })) +jest.mock('node:dns/promises', () => ({ + lookup: jest.fn() +})) describe(FilesManager.name, () => { let service: FilesManager let http: { axiosRef: jest.Mock } + const lookupMock = lookup as jest.Mock let filesQueries: { moveFiles: jest.Mock; deleteFiles: jest.Mock } let spacesManager: { spaceEnv: jest.Mock } let contextManager: { headerOriginUrl: jest.Mock } @@ -120,6 +126,7 @@ describe(FilesManager.name, () => { beforeEach(async () => { http = { axiosRef: jest.fn() } + lookupMock.mockResolvedValue([{ address: '8.8.8.8', family: 4 }]) filesQueries = { moveFiles: jest.fn().mockResolvedValue(undefined), deleteFiles: jest.fn().mockResolvedValue(undefined) @@ -475,7 +482,7 @@ describe(FilesManager.name, () => { } await expect(service.saveMultipart(user, space, req as any)).rejects.toEqual( - new FileError(HttpStatus.PAYLOAD_TOO_LARGE, 'File size limit exceeded') + new FileError(HttpStatus.PAYLOAD_TOO_LARGE, FILE_ERROR_MESSAGES.MAX_FILE_SIZE_EXCEEDED) ) const tmpWritePath = (filesUtils.writeFromStream as jest.Mock).mock.calls[0][0] as string @@ -504,7 +511,7 @@ describe(FilesManager.name, () => { } await expect(service.saveMultipart(user, space, req as any)).rejects.toEqual( - new FileError(HttpStatus.PAYLOAD_TOO_LARGE, 'File size limit exceeded') + new FileError(HttpStatus.PAYLOAD_TOO_LARGE, FILE_ERROR_MESSAGES.MAX_FILE_SIZE_EXCEEDED) ) expect(req.files).toHaveBeenCalled() @@ -528,7 +535,7 @@ describe(FilesManager.name, () => { } await expect(service.saveMultipart(user, space, req as any)).rejects.toEqual( - new FileError(HttpStatus.PAYLOAD_TOO_LARGE, 'File size limit exceeded') + new FileError(HttpStatus.PAYLOAD_TOO_LARGE, FILE_ERROR_MESSAGES.MAX_FILE_SIZE_EXCEEDED) ) expect(filesUtils.writeFromStream).not.toHaveBeenCalled() @@ -892,10 +899,33 @@ describe(FilesManager.name, () => { expect(space.task.props.totalSize).toBe(55) expect(taskEmitSpy).toHaveBeenCalledWith('startWatch', space, FILE_OPERATION.DOWNLOAD, '/tmp/download.txt') - expect(filesUtils.writeFromStream).toHaveBeenCalledWith('/tmp/download.txt', expect.anything()) + expect(filesUtils.writeFromStream).toHaveBeenCalledWith('/tmp/download.txt', expect.anything(), 0, 55) expect(filesLockManager.removeLock).toHaveBeenCalledWith('lock-1') expect(fileEmitSpy).toHaveBeenCalledWith('event', { user, space, action: ACTION.ADD, rPath: '/tmp/download.txt' }) }) + + it('should cleanup partial file and skip ADD event when download write fails', async () => { + const error = new FileError(HttpStatus.PAYLOAD_TOO_LARGE, FILE_ERROR_MESSAGES.MAX_FILE_SIZE_EXCEEDED) + const space = makeSpace() + ;(filesUtils.uniqueFilePathFromDir as jest.Mock).mockResolvedValueOnce('/tmp/download.txt') + ;(filesUtils.writeFromStream as jest.Mock).mockRejectedValueOnce(error) + http.axiosRef + .mockResolvedValueOnce({ + headers: { 'content-length': '55' }, + request: { socket: { remoteAddress: '8.8.8.8' } } + }) + .mockResolvedValueOnce({ + data: Readable.from(['abc']), + request: { socket: { remoteAddress: '8.8.8.8' } } + }) + const fileEmitSpy = jest.spyOn(FileEvent, 'emit') + + await expect(service.downloadFromUrl(user, space, { url: 'https://example.org/file.txt' })).rejects.toBe(error) + + expect(filesUtils.removeFiles).toHaveBeenCalledWith('/tmp/download.txt') + expect(filesLockManager.removeLock).toHaveBeenCalledWith('lock-1') + expect(fileEmitSpy).not.toHaveBeenCalledWith('event', { user, space, action: ACTION.ADD, rPath: '/tmp/download.txt' }) + }) }) describe('compress', () => {
backend/src/applications/files/services/files-manager.service.ts+13 −11 modified@@ -55,13 +55,14 @@ import { } from '../utils/files' import { SendFile } from '../utils/send-file' import { extractZip } from '../utils/unzip-file' -import { downloadFile } from '../utils/download-file' +import { DownloadFile } from '../utils/download-file' import { FilesLockManager } from './files-lock-manager.service' import { FilesQueries } from './files-queries.service' import { FileEvent, FileTaskEvent } from '../events/file-events' import { ACTION } from '../../../common/constants' import { pipeline } from 'node:stream/promises' -import { isMultipartFileTooLargeError, maxUploadSizeExceededError, uploadTmpFilePath } from '../utils/upload-file' +import { isMultipartFileTooLargeError, uploadTmpFilePath } from '../utils/upload-file' +import { FILE_ERROR_MESSAGES, maxFileSizeExceededError } from '../utils/errors' @Injectable() export class FilesManager { @@ -272,7 +273,7 @@ export class FilesManager { await writeFromStream(writePath, part.file) // With throwFileSizeLimit disabled, multipart marks the file stream as truncated instead of rejecting. if (part.file.truncated) { - throw maxUploadSizeExceededError() + throw maxFileSizeExceededError() } if (tmpFile) { // If the following move fails after these deletes, the previous resources remain recoverable from the trash. @@ -296,7 +297,7 @@ export class FilesManager { await removeFiles(dstFile) } if (isMultipartFileTooLargeError(e)) { - throw maxUploadSizeExceededError() + throw maxFileSizeExceededError() } throw e } finally { @@ -317,7 +318,7 @@ export class FilesManager { } } catch (e) { if (isMultipartFileTooLargeError(e)) { - throw maxUploadSizeExceededError() + throw maxFileSizeExceededError() } throw e } @@ -448,8 +449,8 @@ export class FilesManager { if (!isMove || (isMove && srcSpace.id !== dstSpace.id)) { const size = isDir ? (await dirSize(srcSpace.realPath))[0] : await fileSize(srcSpace.realPath) if (dstSpace.willExceedQuota(size)) { - this.logger.warn({ tag: this.copyMove.name, msg: `storage quota will be exceeded for *${dstSpace.alias}* (${dstSpace.id})` }) - throw new FileError(HttpStatus.INSUFFICIENT_STORAGE, 'Storage quota will be exceeded') + this.logger.warn({ tag: this.copyMove.name, msg: `${FILE_ERROR_MESSAGES.STORAGE_QUOTA_EXCEEDED} for *${dstSpace.alias}* (${dstSpace.id})` }) + throw new FileError(HttpStatus.INSUFFICIENT_STORAGE, FILE_ERROR_MESSAGES.STORAGE_QUOTA_EXCEEDED) } } } @@ -579,15 +580,16 @@ export class FilesManager { throw new LockConflict(fileLock, 'Conflicting lock') } - // do try { - await downloadFile(this.http, downloadDto, rPath, { space: space }) + await new DownloadFile(this.http).download(downloadDto, rPath, { space: space }) + } catch (e) { + await removeFiles(rPath).catch((err: Error) => this.logger.error({ tag: this.downloadFromUrl.name, msg: `unable to remove ${rPath} : ${err}` })) + throw e } finally { // release lock await this.filesLockManager.removeLock(fileLock.key) - // emit file event - FileEvent.emit('event', { user, space, action: ACTION.ADD, rPath: rPath }) } + FileEvent.emit('event', { user, space, action: ACTION.ADD, rPath: rPath }) } async compress(user: UserModel, space: SpaceEnv, dto: CompressFileDto): Promise<void> {
backend/src/applications/files/utils/download-file.spec.ts+214 −14 modified@@ -1,41 +1,189 @@ +import { lookup } from 'node:dns/promises' import { HttpService } from '@nestjs/axios' import { HttpStatus } from '@nestjs/common' import { Readable } from 'node:stream' import { HTTP_METHOD } from '../../applications.constants' import { FileError } from '../models/file-error' +import { FILE_ERROR_MESSAGES } from './errors' import { writeFromStream } from './files' -import { downloadFile } from './download-file' +import { DownloadFile } from './download-file' jest.mock('./files', () => ({ writeFromStream: jest.fn() })) +jest.mock('node:dns/promises', () => ({ + lookup: jest.fn() +})) -describe(downloadFile.name, () => { +describe(DownloadFile.name, () => { let http: { axiosRef: jest.Mock } + const lookupMock = lookup as jest.Mock + + const blockedRemoteAddresses = [ + '10.0.0.1', + '100.64.0.0', + '127.0.0.1', + '169.254.169.254', + '192.168.1.1', + '192.0.2.1', + '::1', + 'fc00::1', + 'fe80::1', + '2001:db8::1', + '::ffff:127.0.0.1', + '999.1.1.1' + ] + + const publicRemoteAddresses = ['8.8.8.8', '100.128.0.0', '172.32.0.0', '2001:4860:4860::8888', '::ffff:8.8.8.8'] - const response = (remoteAddress: string, headers: Record<string, string> = {}) => ({ + const response = (remoteAddress: string | undefined, headers: Record<string, string> = {}, status = 200) => ({ + status, headers, request: { socket: { remoteAddress } } }) beforeEach(() => { http = { axiosRef: jest.fn() } + lookupMock.mockResolvedValue([{ address: '8.8.8.8', family: 4 }]) ;(writeFromStream as jest.Mock).mockResolvedValue(undefined) }) afterEach(() => { jest.clearAllMocks() }) - it('rejects private IPs on HEAD by default', async () => { - http.axiosRef.mockResolvedValueOnce(response('127.0.0.1', { 'content-length': '12' })) + it.each(blockedRemoteAddresses)('rejects blocked remote address "%s" on HEAD by default', async (remoteAddress) => { + http.axiosRef.mockResolvedValueOnce(response(remoteAddress, { 'content-length': '12' })) + + await expect( + new DownloadFile(http as unknown as HttpService).download({ url: 'https://example.test/file.txt' }, '/tmp/file.txt') + ).rejects.toEqual(new FileError(HttpStatus.FORBIDDEN, FILE_ERROR_MESSAGES.DOWNLOAD_PRIVATE_IP)) + + expect(http.axiosRef).toHaveBeenCalledTimes(1) + expect(http.axiosRef).toHaveBeenCalledWith( + expect.objectContaining({ method: HTTP_METHOD.HEAD, url: 'https://example.test/file.txt', maxRedirects: 0 }) + ) + expect(writeFromStream).not.toHaveBeenCalled() + }) + + it('rejects blocked DNS resolutions before calling HEAD', async () => { + lookupMock.mockResolvedValueOnce([{ address: '127.0.0.1', family: 4 }]) + + await expect( + new DownloadFile(http as unknown as HttpService).download({ url: 'https://example.test/file.txt' }, '/tmp/file.txt') + ).rejects.toEqual(new FileError(HttpStatus.FORBIDDEN, FILE_ERROR_MESSAGES.DOWNLOAD_PRIVATE_IP)) + + expect(lookupMock).toHaveBeenCalledWith('example.test', { all: true, order: 'verbatim' }) + expect(http.axiosRef).not.toHaveBeenCalled() + expect(writeFromStream).not.toHaveBeenCalled() + }) + + it('rejects mixed DNS resolutions when one address is blocked', async () => { + lookupMock.mockResolvedValueOnce([ + { address: '8.8.8.8', family: 4 }, + { address: '::1', family: 6 } + ]) + + await expect( + new DownloadFile(http as unknown as HttpService).download({ url: 'https://example.test/file.txt' }, '/tmp/file.txt') + ).rejects.toEqual(new FileError(HttpStatus.FORBIDDEN, FILE_ERROR_MESSAGES.DOWNLOAD_PRIVATE_IP)) + + expect(http.axiosRef).not.toHaveBeenCalled() + expect(writeFromStream).not.toHaveBeenCalled() + }) + + it('follows a safe redirect manually', async () => { + lookupMock.mockResolvedValueOnce([{ address: '8.8.8.8', family: 4 }]).mockResolvedValueOnce([{ address: '1.1.1.1', family: 4 }]) + http.axiosRef + .mockResolvedValueOnce(response('8.8.8.8', { location: 'https://cdn.example.test/file.txt' }, 302)) + .mockResolvedValueOnce(response('1.1.1.1', { 'content-length': '12', 'content-type': 'text/plain' })) + + await expect( + new DownloadFile(http as unknown as HttpService).download({ url: 'https://example.test/file.txt' }, '/tmp/file.txt', { getContentInfo: true }) + ).resolves.toEqual({ + contentLength: 12, + contentType: 'text/plain', + lastModified: undefined + }) - await expect(downloadFile(http as unknown as HttpService, { url: 'https://example.test/file.txt' }, '/tmp/file.txt')).rejects.toEqual( - new FileError(HttpStatus.FORBIDDEN, 'Access to internal IP addresses is forbidden') + expect(http.axiosRef).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ method: HTTP_METHOD.HEAD, url: 'https://example.test/file.txt', maxRedirects: 0 }) ) + expect(http.axiosRef).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ method: HTTP_METHOD.HEAD, url: 'https://cdn.example.test/file.txt', maxRedirects: 0 }) + ) + expect(writeFromStream).not.toHaveBeenCalled() + }) + + it('rejects a redirect to a blocked DNS resolution before following it', async () => { + lookupMock.mockResolvedValueOnce([{ address: '8.8.8.8', family: 4 }]).mockResolvedValueOnce([{ address: '127.0.0.1', family: 4 }]) + http.axiosRef.mockResolvedValueOnce(response('8.8.8.8', { location: 'https://internal.example.test/file.txt' }, 302)) + + await expect( + new DownloadFile(http as unknown as HttpService).download({ url: 'https://example.test/file.txt' }, '/tmp/file.txt') + ).rejects.toEqual(new FileError(HttpStatus.FORBIDDEN, FILE_ERROR_MESSAGES.DOWNLOAD_PRIVATE_IP)) + + expect(http.axiosRef).toHaveBeenCalledTimes(1) + expect(writeFromStream).not.toHaveBeenCalled() + }) + + it('rejects a missing remote address on HEAD by default', async () => { + http.axiosRef.mockResolvedValueOnce(response(undefined, { 'content-length': '12' })) + + await expect( + new DownloadFile(http as unknown as HttpService).download({ url: 'https://example.test/file.txt' }, '/tmp/file.txt') + ).rejects.toEqual(new FileError(HttpStatus.FORBIDDEN, FILE_ERROR_MESSAGES.DOWNLOAD_PRIVATE_IP)) + + expect(http.axiosRef).toHaveBeenCalledTimes(1) + expect(writeFromStream).not.toHaveBeenCalled() + }) + + it.each(publicRemoteAddresses)('allows public remote address "%s" on HEAD by default', async (remoteAddress) => { + http.axiosRef.mockResolvedValueOnce(response(remoteAddress, { 'content-length': '12', 'content-type': 'text/plain' })) + + await expect( + new DownloadFile(http as unknown as HttpService).download({ url: 'https://example.test/file.txt' }, '/tmp/file.txt', { getContentInfo: true }) + ).resolves.toEqual({ + contentLength: 12, + contentType: 'text/plain', + lastModified: undefined + }) expect(http.axiosRef).toHaveBeenCalledTimes(1) - expect(http.axiosRef).toHaveBeenCalledWith({ method: HTTP_METHOD.HEAD, url: 'https://example.test/file.txt', maxRedirects: 1 }) + expect(http.axiosRef).toHaveBeenCalledWith( + expect.objectContaining({ + httpAgent: expect.any(Object), + httpsAgent: expect.any(Object) + }) + ) + expect(writeFromStream).not.toHaveBeenCalled() + }) + + it('uses a guarded agent lookup for connection-time resolutions', async () => { + lookupMock.mockResolvedValueOnce([{ address: '8.8.8.8', family: 4 }]).mockResolvedValueOnce([{ address: '127.0.0.1', family: 4 }]) + http.axiosRef.mockImplementationOnce( + (config: { + httpAgent: { + options: { lookup: (hostname: string, options: Record<string, unknown>, callback: (e: Error | null) => void) => void } + } + }) => + new Promise((resolve, reject) => { + config.httpAgent.options.lookup('redirect.test', {}, (e: Error | null) => { + if (e) { + reject(e) + } else { + resolve(response('8.8.8.8', { 'content-length': '12', 'content-type': 'text/plain' })) + } + }) + }) + ) + + await expect( + new DownloadFile(http as unknown as HttpService).download({ url: 'https://example.test/file.txt' }, '/tmp/file.txt', { getContentInfo: true }) + ).rejects.toEqual(new FileError(HttpStatus.FORBIDDEN, FILE_ERROR_MESSAGES.DOWNLOAD_PRIVATE_IP)) + expect(writeFromStream).not.toHaveBeenCalled() }) @@ -48,7 +196,7 @@ describe(downloadFile.name, () => { }) ) - const result = await downloadFile(http as unknown as HttpService, { url: 'https://example.test/avatar.png' }, '/tmp/avatar.png', { + const result = await new DownloadFile(http as unknown as HttpService).download({ url: 'https://example.test/avatar.png' }, '/tmp/avatar.png', { allowPrivateIP: true, getContentInfo: true }) @@ -69,10 +217,48 @@ describe(downloadFile.name, () => { .mockResolvedValueOnce(response('8.8.8.8', { 'content-length': '12' })) .mockResolvedValueOnce({ ...response('10.0.0.7'), data: stream }) - await expect(downloadFile(http as unknown as HttpService, { url: 'https://example.test/file.txt' }, '/tmp/file.txt')).rejects.toEqual( - new FileError(HttpStatus.FORBIDDEN, 'Access to internal IP addresses is forbidden') - ) + await expect( + new DownloadFile(http as unknown as HttpService).download({ url: 'https://example.test/file.txt' }, '/tmp/file.txt') + ).rejects.toEqual(new FileError(HttpStatus.FORBIDDEN, FILE_ERROR_MESSAGES.DOWNLOAD_PRIVATE_IP)) + + expect(destroySpy).toHaveBeenCalled() + expect(writeFromStream).not.toHaveBeenCalled() + }) + + it.each(['', '-1', '1.5', 'Infinity', '9007199254740992', 'abc'])('rejects invalid content-length "%s"', async (contentLength) => { + http.axiosRef.mockResolvedValueOnce(response('8.8.8.8', { 'content-length': contentLength })) + await expect( + new DownloadFile(http as unknown as HttpService).download({ url: 'https://example.test/file.txt' }, '/tmp/file.txt') + ).rejects.toEqual(new FileError(HttpStatus.BAD_REQUEST, FILE_ERROR_MESSAGES.DOWNLOAD_INVALID_CONTENT_LENGTH)) + + expect(http.axiosRef).toHaveBeenCalledTimes(1) + expect(writeFromStream).not.toHaveBeenCalled() + }) + + it('allows zero content-length and guards the written stream at zero bytes', async () => { + const stream = Readable.from([]) + http.axiosRef + .mockResolvedValueOnce(response('8.8.8.8', { 'content-length': '0' })) + .mockResolvedValueOnce({ ...response('8.8.8.8'), data: stream }) + + await new DownloadFile(http as unknown as HttpService).download({ url: 'https://example.test/file.txt' }, '/tmp/file.txt') + + expect(writeFromStream).toHaveBeenCalledWith('/tmp/file.txt', stream, 0, 0) + }) + + it('rejects redirects on GET after the HEAD URL has been resolved', async () => { + const stream = Readable.from(['abc']) + const destroySpy = jest.spyOn(stream, 'destroy') + http.axiosRef + .mockResolvedValueOnce(response('8.8.8.8', { 'content-length': '12' })) + .mockResolvedValueOnce({ ...response('8.8.8.8', { location: 'https://cdn.example.test/file.txt' }, 302), data: stream }) + + await expect( + new DownloadFile(http as unknown as HttpService).download({ url: 'https://example.test/file.txt' }, '/tmp/file.txt') + ).rejects.toEqual(new FileError(HttpStatus.BAD_REQUEST, FILE_ERROR_MESSAGES.DOWNLOAD_MAX_REDIRECTS_EXCEEDED)) + + expect(http.axiosRef).toHaveBeenCalledTimes(2) expect(destroySpy).toHaveBeenCalled() expect(writeFromStream).not.toHaveBeenCalled() }) @@ -83,8 +269,22 @@ describe(downloadFile.name, () => { .mockResolvedValueOnce(response('127.0.0.1', { 'content-length': '12' })) .mockResolvedValueOnce({ ...response('10.0.0.7'), data: stream }) - await downloadFile(http as unknown as HttpService, { url: 'https://example.test/file.txt' }, '/tmp/file.txt', { allowPrivateIP: true }) + await new DownloadFile(http as unknown as HttpService).download({ url: 'https://example.test/file.txt' }, '/tmp/file.txt', { + allowPrivateIP: true + }) - expect(writeFromStream).toHaveBeenCalledWith('/tmp/file.txt', stream) + expect(writeFromStream).toHaveBeenCalledWith('/tmp/file.txt', stream, 0, 12) + expect(http.axiosRef).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + method: HTTP_METHOD.GET, + url: 'https://example.test/file.txt', + responseType: 'stream', + decompress: false, + headers: { 'Accept-Encoding': 'identity' }, + proxy: false, + maxRedirects: 0 + }) + ) }) })
backend/src/applications/files/utils/download-file.ts+159 −79 modified@@ -1,97 +1,177 @@ +import { lookup } from 'node:dns/promises' +import { Agent as HttpAgent } from 'node:http' +import { Agent as HttpsAgent } from 'node:https' +import type { LookupAddress } from 'node:dns' +import type { LookupFunction } from 'node:net' import type { HttpService } from '@nestjs/axios' -import type { SpaceEnv } from '../../spaces/models/space-env.model' -import type { AxiosResponse } from 'axios' -import { HTTP_METHOD } from '../../applications.constants' -import { FileError } from '../models/file-error' import { HttpStatus } from '@nestjs/common' -import { FileTaskEvent } from '../events/file-events' +import { AxiosHeaders, type AxiosRequestConfig, type AxiosResponse } from 'axios' +import ipaddr from 'ipaddr.js' +import { HTTP_METHOD } from '../../applications.constants' +import type { SpaceEnv } from '../../spaces/models/space-env.model' import { FILE_OPERATION } from '../constants/operations' -import { writeFromStream } from './files' import type { DownloadFileDto } from '../dto/file-operations.dto' +import { FileTaskEvent } from '../events/file-events' import type { DownloadFileContentInfo, DownloadFileOptions } from '../interfaces/download-file.interface' +import { FileError } from '../models/file-error' +import { FILE_ERROR_MESSAGES } from './errors' +import { writeFromStream } from './files' + +interface DownloadFileRequestOptions { + allowPrivateIP?: boolean + maxRedirects?: number +} + +export class DownloadFile { + private static readonly dnsOptions = { all: true, order: 'verbatim' } as const + private static readonly maxRedirects = 1 + private static readonly redirectStatus = new Set([301, 302, 303, 307, 308]) + private readonly dnsCache = new Map<string, LookupAddress[]>() + private readonly safeAgents = { + httpAgent: new HttpAgent({ lookup: this.safeLookup.bind(this) }), + httpsAgent: new HttpsAgent({ lookup: this.safeLookup.bind(this) }) + } + + constructor(private readonly http: HttpService) {} + + async download( + downloadDto: DownloadFileDto, + dstPath: string, + options: { allowPrivateIP?: boolean; space?: SpaceEnv; getContentInfo: true } + ): Promise<DownloadFileContentInfo> + async download( + downloadDto: DownloadFileDto, + dstPath: string, + options?: { allowPrivateIP?: boolean; space?: SpaceEnv; getContentInfo?: false | undefined } + ): Promise<void> + async download(downloadDto: DownloadFileDto, dstPath: string, options?: DownloadFileOptions): Promise<void | DownloadFileContentInfo> { + const { response: headRes, url } = await this.request(downloadDto.url, { method: HTTP_METHOD.HEAD }, { allowPrivateIP: options?.allowPrivateIP }) + + const headers = AxiosHeaders.from(headRes.headers) + const contentLength = this.contentLength(headers) + if (options?.getContentInfo) { + return { + contentLength, + contentType: `${headers.getContentType()}`, + lastModified: headers.get('last-modified') as string | undefined + } satisfies DownloadFileContentInfo + } -const parts = [ - // IPv4 loopback (127.0.0.0/8) - '127\\.(?:\\d{1,3}\\.){2}\\d{1,3}', - // IPv4 link-local (169.254.0.0/16) - '169\\.254\\.\\d{1,3}\\.\\d{1,3}', - // IPv4 Carrier-grade NAT (100.64.0.0/10) - '100\\.(?:6[4-9]|[7-9]\\d|1[01]\\d|12[0-7])\\.\\d{1,3}\\.\\d{1,3}', - // IPv4 private (10.0.0.0/8) - '10\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}', - // IPv4 private (192.168.0.0/16) - '192\\.168\\.\\d{1,3}\\.\\d{1,3}', - // IPv4 private (172.16.0.0/12) - '172\\.(?:1[6-9]|2\\d|3[0-1])\\.\\d{1,3}\\.\\d{1,3}', - // IPv4 & IPv6 loopback - '::1', - '::', - '0.0.0.0', - // IPv6 Unique Local Address (fc00::/7) - 'f[cd][0-9a-f]{2}:[0-9a-f:]+', - // IPv6 link-local (fe80::/10) - 'fe[89ab][0-9a-f]{2}:[0-9a-f:]+' -] - -const regExpPrivateIP = new RegExp(`^(?:${parts.join('|')})$`, 'i') -const errorRegexpPrivateIP = 'Access to internal IP addresses is forbidden' - -export async function downloadFile( - http: HttpService, - downloadDto: DownloadFileDto, - dstPath: string, - options: { allowPrivateIP?: boolean; space?: SpaceEnv; getContentInfo: true } -): Promise<DownloadFileContentInfo> -export async function downloadFile( - http: HttpService, - downloadDto: DownloadFileDto, - dstPath: string, - options?: { allowPrivateIP?: boolean; space?: SpaceEnv; getContentInfo?: false | undefined } -): Promise<void> -export async function downloadFile( - http: HttpService, - downloadDto: DownloadFileDto, - dstPath: string, - options?: DownloadFileOptions -): Promise<void | DownloadFileContentInfo> { - // dto must be validated by the caller - const headRes: AxiosResponse = await http.axiosRef({ method: HTTP_METHOD.HEAD, url: downloadDto.url, maxRedirects: 1 }) - if (!options?.allowPrivateIP && regExpPrivateIP.test(headRes.request.socket.remoteAddress)) { - // prevent SSRF attack - throw new FileError(HttpStatus.FORBIDDEN, errorRegexpPrivateIP) + if (contentLength === null) throw new FileError(HttpStatus.BAD_REQUEST, FILE_ERROR_MESSAGES.DOWNLOAD_INVALID_CONTENT_LENGTH) + this.prepareSpace(options?.space, contentLength, dstPath) + + // The HEAD request resolved redirects; the GET must target that final URL directly. + const { response: getRes } = await this.request( + url, + { method: HTTP_METHOD.GET, responseType: 'stream', decompress: false, headers: { 'Accept-Encoding': 'identity' } }, + { allowPrivateIP: options?.allowPrivateIP, maxRedirects: 0 } + ) + await writeFromStream(dstPath, getRes.data, 0, contentLength) } - // attempt to retrieve the Content-Length header - const contentLength = 'content-length' in headRes.headers ? Number(headRes.headers['content-length']) || null : null - if (options?.getContentInfo) { - return { - contentLength: contentLength, - contentType: `${headRes.headers['content-type']}`, - lastModified: headRes.headers['last-modified'] as string | undefined - } satisfies DownloadFileContentInfo + private safeLookup(hostname: string, options: Parameters<LookupFunction>[1], cb: Parameters<LookupFunction>[2]): void { + this.resolvePublic(hostname) + .then((addresses) => { + const family = options.family === 4 || options.family === 'IPv4' ? 4 : options.family === 6 || options.family === 'IPv6' ? 6 : null + const matches = family ? addresses.filter((a) => a.family === family) : addresses + if (!matches.length) return cb(new FileError(HttpStatus.FORBIDDEN, FILE_ERROR_MESSAGES.DOWNLOAD_PRIVATE_IP), '', 0) + if (options.all) { + cb(null, matches) + } else { + cb(null, matches[0].address, matches[0].family) + } + }) + .catch((e: Error) => cb(e as FileError, '', 0)) } - if (!contentLength) { - throw new FileError(HttpStatus.BAD_REQUEST, 'Missing "content-length" header') + private async request( + url: string, + config: AxiosRequestConfig, + options: DownloadFileRequestOptions = {} + ): Promise<{ response: AxiosResponse; url: string }> { + let currentUrl = url + for (let redirects = 0; ; redirects++) { + if (!options.allowPrivateIP) await this.resolvePublic(new URL(currentUrl).hostname) + const response: AxiosResponse = await this.http.axiosRef({ + ...config, + url: currentUrl, + proxy: false, + maxRedirects: 0, + validateStatus: (status) => status >= 200 && status < 400, + ...(options.allowPrivateIP ? {} : this.safeAgents) + }) + try { + this.checkRemote(response, options.allowPrivateIP) + } catch (e) { + response.data?.destroy?.() + throw e + } + const nextUrl = this.redirectUrl(response, currentUrl) + if (!nextUrl) return { response, url: currentUrl } + + response.data?.destroy?.() + // Redirects are followed manually to re-run DNS and remote address checks on each hop. + if (redirects >= (options.maxRedirects ?? DownloadFile.maxRedirects)) { + throw new FileError(HttpStatus.BAD_REQUEST, FILE_ERROR_MESSAGES.DOWNLOAD_MAX_REDIRECTS_EXCEEDED) + } + currentUrl = nextUrl + } } - if (options?.space) { - if (options.space.willExceedQuota(contentLength)) { - throw new FileError(HttpStatus.INSUFFICIENT_STORAGE, 'Storage quota will be exceeded') + private redirectUrl(response: AxiosResponse, currentUrl: string): string | null { + if (!DownloadFile.redirectStatus.has(response.status)) return null + const location = response.headers.location as string | undefined + if (!location) throw new FileError(HttpStatus.BAD_REQUEST, FILE_ERROR_MESSAGES.DOWNLOAD_MISSING_REDIRECT_LOCATION) + + const url = new URL(location, currentUrl) + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new FileError(HttpStatus.FORBIDDEN, FILE_ERROR_MESSAGES.DOWNLOAD_UNSAFE_REDIRECT_LOCATION) } - // tasking - if (options.space.task?.cacheKey) { - options.space.task.props.totalSize = contentLength - FileTaskEvent.emit('startWatch', options.space, FILE_OPERATION.DOWNLOAD, dstPath) + return url.toString() + } + + private async resolvePublic(hostname: string): Promise<LookupAddress[]> { + const key = this.normalizeHostname(hostname).toLowerCase() + // Keep DNS answers stable within one download attempt and avoid a second resolution drift. + const cached = this.dnsCache.get(key) + if (cached) return cached + + const addresses = await lookup(key, DownloadFile.dnsOptions) + if (!addresses.length || addresses.some((a) => this.isBlocked(a.address))) { + throw new FileError(HttpStatus.FORBIDDEN, FILE_ERROR_MESSAGES.DOWNLOAD_PRIVATE_IP) + } + this.dnsCache.set(key, addresses) + return addresses + } + + private normalizeHostname(hostname: string): string { + return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname + } + + private checkRemote(response: AxiosResponse, allowPrivateIP?: boolean): void { + if (!allowPrivateIP && this.isBlocked(response.request?.socket?.remoteAddress)) { + throw new FileError(HttpStatus.FORBIDDEN, FILE_ERROR_MESSAGES.DOWNLOAD_PRIVATE_IP) } } - const getRes = await http.axiosRef({ method: HTTP_METHOD.GET, url: downloadDto.url, responseType: 'stream', maxRedirects: 1 }) - if (!options?.allowPrivateIP && regExpPrivateIP.test(getRes.request.socket.remoteAddress)) { - // close request - getRes.data?.destroy() - // Prevent SSRF attacks and perform a DNS-rebinding check if a HEAD request has already been made - throw new FileError(HttpStatus.FORBIDDEN, errorRegexpPrivateIP) + private isBlocked(address: string | undefined): boolean { + return !address || !ipaddr.isValid(address) || ipaddr.process(address).range() !== 'unicast' + } + + private contentLength(headers: AxiosHeaders): number | null { + const value = headers.getContentLength(/^\d+$/)?.[0] + const contentLength = Number(value) + return Number.isSafeInteger(contentLength) ? contentLength : null + } + + private prepareSpace(space: SpaceEnv | undefined, contentLength: number, dstPath: string): void { + if (!space) return + if (space.willExceedQuota(contentLength)) { + throw new FileError(HttpStatus.INSUFFICIENT_STORAGE, FILE_ERROR_MESSAGES.STORAGE_QUOTA_EXCEEDED) + } + if (space.task?.cacheKey) { + space.task.props.totalSize = contentLength + FileTaskEvent.emit('startWatch', space, FILE_OPERATION.DOWNLOAD, dstPath) + } } - await writeFromStream(dstPath, getRes.data) }
backend/src/applications/files/utils/errors.ts+16 −0 added@@ -0,0 +1,16 @@ +import { FileError } from '../models/file-error' +import { HttpStatus } from '@nestjs/common' + +export const FILE_ERROR_MESSAGES = { + DOWNLOAD_PRIVATE_IP: 'Access to internal IP addresses is forbidden', + DOWNLOAD_INVALID_CONTENT_LENGTH: 'Missing or invalid "content-length" header', + DOWNLOAD_MAX_REDIRECTS_EXCEEDED: 'Maximum redirects exceeded', + DOWNLOAD_MISSING_REDIRECT_LOCATION: 'Missing redirect location', + DOWNLOAD_UNSAFE_REDIRECT_LOCATION: 'Unsafe redirect location', + MAX_FILE_SIZE_EXCEEDED: 'File size limit exceeded', + STORAGE_QUOTA_EXCEEDED: 'Storage quota will be exceeded' +} as const + +export function maxFileSizeExceededError(): FileError { + return new FileError(HttpStatus.PAYLOAD_TOO_LARGE, FILE_ERROR_MESSAGES.MAX_FILE_SIZE_EXCEEDED) +}
backend/src/applications/files/utils/files.spec.ts+47 −0 added@@ -0,0 +1,47 @@ +import { HttpStatus } from '@nestjs/common' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { Readable } from 'node:stream' +import { FileError } from '../models/file-error' +import { FILE_ERROR_MESSAGES } from './errors' +import { writeFromStream } from './files' + +describe(writeFromStream.name, () => { + let tmpDir: string + + beforeEach(async () => { + tmpDir = await mkdtemp(path.join(os.tmpdir(), 'write-from-stream-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('writes a stream matching the max size', async () => { + const filePath = path.join(tmpDir, 'file.txt') + + await writeFromStream(filePath, Readable.from([Buffer.from('abc')]), 0, 3) + + await expect(readFile(filePath, 'utf8')).resolves.toBe('abc') + }) + + it('rejects a stream exceeding the max size', async () => { + const filePath = path.join(tmpDir, 'file.txt') + + await expect(writeFromStream(filePath, Readable.from([Buffer.from('abcd')]), 0, 3)).rejects.toMatchObject({ + httpCode: HttpStatus.PAYLOAD_TOO_LARGE, + message: FILE_ERROR_MESSAGES.MAX_FILE_SIZE_EXCEEDED, + name: FileError.name + }) + }) + + it('accounts for the existing start offset', async () => { + const filePath = path.join(tmpDir, 'file.txt') + await writeFile(filePath, 'abc') + + await writeFromStream(filePath, Readable.from([Buffer.from('de')]), 3, 5) + + await expect(readFile(filePath, 'utf8')).resolves.toBe('abcde') + }) +})
backend/src/applications/files/utils/files.ts+18 −3 modified@@ -6,14 +6,15 @@ import crypto from 'node:crypto' import { createReadStream, createWriteStream, Dirent, statSync } from 'node:fs' import fs from 'node:fs/promises' import path from 'node:path' -import { Readable } from 'node:stream' +import { Readable, Transform } from 'node:stream' import { pipeline } from 'node:stream/promises' import { formatDateISOString } from '../../../common/functions' import { currentTimeStamp, isValidFileName, regExpPreventPathTraversal } from '../../../common/shared' import { DEFAULT_CHECKSUM_ALGORITHM, DEFAULT_HIGH_WATER_MARK, EXTRA_MIMES_TYPE } from '../constants/files' import type { FileDBProps } from '../interfaces/file-db-props.interface' import type { FileProps } from '../interfaces/file-props.interface' import { FileError } from '../models/file-error' +import { maxFileSizeExceededError } from './errors' export function sanitizePath(fPath: string): string { return path.normalize(fPath).replace(regExpPreventPathTraversal, '') @@ -178,9 +179,23 @@ export async function checksumFile(filePath: string, alg: string): Promise<strin return hash.digest('hex') } -export function writeFromStream(rPath: string, stream: Readable, start: number = 0): Promise<void> { +export function writeFromStream(rPath: string, stream: Readable, start: number = 0, maxSize?: number): Promise<void> { const dst: WriteStream = createWriteStream(rPath, { flags: start ? 'a' : 'w', start: start, highWaterMark: DEFAULT_HIGH_WATER_MARK }) - return pipeline(stream, dst) + if (maxSize === undefined) { + return pipeline(stream, dst) + } + let received = start + const limitSize = new Transform({ + transform(chunk, _encoding, callback) { + received += chunk.length + if (received > maxSize) { + callback(maxFileSizeExceededError()) + return + } + callback(null, chunk) + } + }) + return pipeline(stream, limitSize, dst) } export async function writeFromStreamAndChecksum(rPath: string, stream: Readable, hasRange: number, alg: string): Promise<string> {
backend/src/applications/files/utils/upload-file.ts+0 −7 modified@@ -1,21 +1,14 @@ import path from 'node:path' import { randomUUID } from 'node:crypto' -import { FileError } from '../models/file-error' -import { HttpStatus } from '@nestjs/common' import { fileName } from './files' const FASTIFY_MULTIPART_FILE_TOO_LARGE_CODE = 'FST_REQ_FILE_TOO_LARGE' as const -const MAX_UPLOAD_SIZE_EXCEEDED = 'File size limit exceeded' as const export function isMultipartFileTooLargeError(e: any): boolean { // Other multipart limits also return 413; only this code means the file-size limit was reached. return e?.code === FASTIFY_MULTIPART_FILE_TOO_LARGE_CODE } -export function maxUploadSizeExceededError(): FileError { - return new FileError(HttpStatus.PAYLOAD_TOO_LARGE, MAX_UPLOAD_SIZE_EXCEEDED) -} - export function uploadTmpFilePath(tmpPath: string, partFileName: string): string { return path.join(tmpPath, `${randomUUID()}-upload-${fileName(partFileName) || 'file'}`) }
backend/src/applications/spaces/guards/space.guard.ts+2 −1 modified@@ -17,6 +17,7 @@ import { SpaceEnv } from '../models/space-env.model' import { SpacesManager } from '../services/spaces-manager.service' import { canAccessToSpaceUrl, haveSpaceEnvPermissions } from '../utils/permissions' import { PATH_TO_SPACE_SEGMENTS } from '../utils/routes' +import { FILE_ERROR_MESSAGES } from '../../files/utils/errors' @Injectable() export class SpaceGuard implements CanActivate { @@ -51,7 +52,7 @@ export class SpaceGuard implements CanActivate { } else if (req.space.storageQuota) { const contentLength = parseInt(req.headers['content-length'] || '0', 10) || 0 if (req.space.willExceedQuota(contentLength)) { - throw new HttpException('Storage quota will be exceeded', HttpStatus.INSUFFICIENT_STORAGE) + throw new HttpException(FILE_ERROR_MESSAGES.STORAGE_QUOTA_EXCEEDED, HttpStatus.INSUFFICIENT_STORAGE) } } }
backend/src/app.module.ts+3 −2 modified@@ -34,8 +34,9 @@ import { SchedulerModule } from './infrastructure/scheduler/scheduler.module' headers: { 'User-Agent': USER_AGENT }, - timeout: 5000, - maxRedirects: 5 + proxy: false, + timeout: 6000, + maxRedirects: 0 }) ], providers: [AppService]
backend/src/authentication/providers/oidc/auth-provider-oidc.service.spec.ts+6 −6 modified@@ -16,7 +16,7 @@ import { AdminUsersManager } from '../../../applications/users/services/admin-us import { UsersManager } from '../../../applications/users/services/users-manager.service' import * as avatarUtils from '../../../applications/users/utils/avatar' import * as filesUtils from '../../../applications/files/utils/files' -import * as downloadFileUtils from '../../../applications/files/utils/download-file' +import { DownloadFile } from '../../../applications/files/utils/download-file' import * as imageUtils from '../../../common/image' import { DEFAULT_STORAGE_QUOTA_FIELD } from '../auth-providers.constants' import { OAuthCookie } from './auth-oidc.constants' @@ -381,15 +381,15 @@ describe(AuthProviderOIDC.name, () => { const userInfo = (picture = 'https://cdn.example.test/avatar.jpg') => ({ picture }) as any it('returns when picture url is invalid', async () => { - const downloadSpy = jest.spyOn(downloadFileUtils, 'downloadFile') + const downloadSpy = jest.spyOn(DownloadFile.prototype, 'download') await (service as any).updatePictureUrl(oidcUser, userInfo('not-a-url')) expect(downloadSpy).not.toHaveBeenCalled() }) it('stops when content type is not an image', async () => { - const downloadSpy = jest.spyOn(downloadFileUtils, 'downloadFile').mockResolvedValueOnce({ + const downloadSpy = jest.spyOn(DownloadFile.prototype, 'download').mockResolvedValueOnce({ contentType: 'text/plain', contentLength: 123, lastModified: 'Mon, 01 Jan 2024 00:00:00 GMT' @@ -403,7 +403,7 @@ describe(AuthProviderOIDC.name, () => { }) it('skips update when avatar metadata is unchanged', async () => { - const downloadSpy = jest.spyOn(downloadFileUtils, 'downloadFile').mockResolvedValueOnce({ + const downloadSpy = jest.spyOn(DownloadFile.prototype, 'download').mockResolvedValueOnce({ contentType: 'image/png', contentLength: 128, lastModified: 'Mon, 01 Jan 2024 00:00:00 GMT' @@ -419,7 +419,7 @@ describe(AuthProviderOIDC.name, () => { it('downloads and converts avatar when checks pass', async () => { const downloadSpy = jest - .spyOn(downloadFileUtils, 'downloadFile') + .spyOn(DownloadFile.prototype, 'download') .mockResolvedValueOnce({ contentType: 'image/png', contentLength: 128, @@ -441,7 +441,7 @@ describe(AuthProviderOIDC.name, () => { it('stops after download when avatar size exceeds limit', async () => { const downloadSpy = jest - .spyOn(downloadFileUtils, 'downloadFile') + .spyOn(DownloadFile.prototype, 'download') .mockResolvedValueOnce({ contentType: 'image/png', contentLength: 128,
backend/src/authentication/providers/oidc/auth-provider-oidc.service.ts+4 −3 modified@@ -44,7 +44,7 @@ import type { AuthProviderOIDCConfig } from './auth-oidc.config' import { OAuthCookie, OAuthCookieSettings, OAuthTokenEndpoint } from './auth-oidc.constants' import { HttpService } from '@nestjs/axios' import { DownloadFileDto } from '../../../applications/files/dto/file-operations.dto' -import { downloadFile } from '../../../applications/files/utils/download-file' +import { DownloadFile } from '../../../applications/files/utils/download-file' import { convertTempImageToPng, imgMimeTypePrefix } from '../../../common/image' import { fileSize } from '../../../applications/files/utils/files' @@ -433,10 +433,11 @@ export class AuthProviderOIDC implements AuthProvider { // checks let pictureContentLength: number | undefined let pictureLastModified: string | undefined + const downloader = new DownloadFile(this.http) try { const tmpPicturePath = path.join(user.tmpPath, USER_AVATAR_FILE_NAME) // retrieve headers - const { contentType, contentLength, lastModified } = await downloadFile(this.http, downloadDto, tmpPicturePath, { + const { contentType, contentLength, lastModified } = await downloader.download(downloadDto, tmpPicturePath, { allowPrivateIP: true, // trust the url source getContentInfo: true }) @@ -464,7 +465,7 @@ export class AuthProviderOIDC implements AuthProvider { // download avatar (trust the url source) const userAvatarTmpPath = path.join(user.tmpPath, USER_AVATAR_FILE_NAME) try { - await downloadFile(this.http, downloadDto, userAvatarTmpPath, { allowPrivateIP: true }) + await downloader.download(downloadDto, userAvatarTmpPath, { allowPrivateIP: true }) } catch (e) { this.logger.warn({ tag: this.updatePictureUrl.name, msg: `download failed: ${e}` }) return
package-lock.json+1 −0 modified@@ -72,6 +72,7 @@ "fast-xml-parser": "^5.0.7", "fs-extra": "^11.2.0", "html-to-text": "^10.0.0", + "ipaddr.js": "^2.4.0", "js-yaml": "^4.1.0", "ldapts": "^8.0.36", "mime-types": "^3.0.1",
44261ea20e83feat(backend:files): support trusted private IP downloads
3 files changed · +95 −4
backend/src/applications/files/interfaces/download-file.interface.ts+1 −0 modified@@ -7,6 +7,7 @@ export interface DownloadFileContentInfo { } export interface DownloadFileOptions { + allowPrivateIP?: boolean space?: SpaceEnv getContentInfo?: boolean }
backend/src/applications/files/utils/download-file.spec.ts+90 −0 added@@ -0,0 +1,90 @@ +import { HttpService } from '@nestjs/axios' +import { HttpStatus } from '@nestjs/common' +import { Readable } from 'node:stream' +import { HTTP_METHOD } from '../../applications.constants' +import { FileError } from '../models/file-error' +import { writeFromStream } from './files' +import { downloadFile } from './download-file' + +jest.mock('./files', () => ({ + writeFromStream: jest.fn() +})) + +describe(downloadFile.name, () => { + let http: { axiosRef: jest.Mock } + + const response = (remoteAddress: string, headers: Record<string, string> = {}) => ({ + headers, + request: { socket: { remoteAddress } } + }) + + beforeEach(() => { + http = { axiosRef: jest.fn() } + ;(writeFromStream as jest.Mock).mockResolvedValue(undefined) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('rejects private IPs on HEAD by default', async () => { + http.axiosRef.mockResolvedValueOnce(response('127.0.0.1', { 'content-length': '12' })) + + await expect(downloadFile(http as unknown as HttpService, { url: 'https://example.test/file.txt' }, '/tmp/file.txt')).rejects.toEqual( + new FileError(HttpStatus.FORBIDDEN, 'Access to internal IP addresses is forbidden') + ) + + expect(http.axiosRef).toHaveBeenCalledTimes(1) + expect(http.axiosRef).toHaveBeenCalledWith({ method: HTTP_METHOD.HEAD, url: 'https://example.test/file.txt', maxRedirects: 1 }) + expect(writeFromStream).not.toHaveBeenCalled() + }) + + it('allows private IPs on HEAD when allowPrivateIP is enabled for content info', async () => { + http.axiosRef.mockResolvedValueOnce( + response('127.0.0.1', { + 'content-length': '12', + 'content-type': 'image/png', + 'last-modified': 'Mon, 01 Jan 2024 00:00:00 GMT' + }) + ) + + const result = await downloadFile(http as unknown as HttpService, { url: 'https://example.test/avatar.png' }, '/tmp/avatar.png', { + allowPrivateIP: true, + getContentInfo: true + }) + + expect(result).toEqual({ + contentLength: 12, + contentType: 'image/png', + lastModified: 'Mon, 01 Jan 2024 00:00:00 GMT' + }) + expect(http.axiosRef).toHaveBeenCalledTimes(1) + expect(writeFromStream).not.toHaveBeenCalled() + }) + + it('rejects private IPs on GET by default and closes the stream', async () => { + const stream = Readable.from(['abc']) + const destroySpy = jest.spyOn(stream, 'destroy') + http.axiosRef + .mockResolvedValueOnce(response('8.8.8.8', { 'content-length': '12' })) + .mockResolvedValueOnce({ ...response('10.0.0.7'), data: stream }) + + await expect(downloadFile(http as unknown as HttpService, { url: 'https://example.test/file.txt' }, '/tmp/file.txt')).rejects.toEqual( + new FileError(HttpStatus.FORBIDDEN, 'Access to internal IP addresses is forbidden') + ) + + expect(destroySpy).toHaveBeenCalled() + expect(writeFromStream).not.toHaveBeenCalled() + }) + + it('allows private IPs on GET when allowPrivateIP is enabled', async () => { + const stream = Readable.from(['abc']) + http.axiosRef + .mockResolvedValueOnce(response('127.0.0.1', { 'content-length': '12' })) + .mockResolvedValueOnce({ ...response('10.0.0.7'), data: stream }) + + await downloadFile(http as unknown as HttpService, { url: 'https://example.test/file.txt' }, '/tmp/file.txt', { allowPrivateIP: true }) + + expect(writeFromStream).toHaveBeenCalledWith('/tmp/file.txt', stream) + }) +})
backend/src/applications/files/utils/download-file.ts+4 −4 modified@@ -40,13 +40,13 @@ export async function downloadFile( http: HttpService, downloadDto: DownloadFileDto, dstPath: string, - options: { space?: SpaceEnv; getContentInfo: true } + options: { allowPrivateIP?: boolean; space?: SpaceEnv; getContentInfo: true } ): Promise<DownloadFileContentInfo> export async function downloadFile( http: HttpService, downloadDto: DownloadFileDto, dstPath: string, - options?: { space?: SpaceEnv; getContentInfo?: false | undefined } + options?: { allowPrivateIP?: boolean; space?: SpaceEnv; getContentInfo?: false | undefined } ): Promise<void> export async function downloadFile( http: HttpService, @@ -56,7 +56,7 @@ export async function downloadFile( ): Promise<void | DownloadFileContentInfo> { // dto must be validated by the caller const headRes: AxiosResponse = await http.axiosRef({ method: HTTP_METHOD.HEAD, url: downloadDto.url, maxRedirects: 1 }) - if (regExpPrivateIP.test(headRes.request.socket.remoteAddress)) { + if (!options?.allowPrivateIP && regExpPrivateIP.test(headRes.request.socket.remoteAddress)) { // prevent SSRF attack throw new FileError(HttpStatus.FORBIDDEN, errorRegexpPrivateIP) } @@ -87,7 +87,7 @@ export async function downloadFile( } const getRes = await http.axiosRef({ method: HTTP_METHOD.GET, url: downloadDto.url, responseType: 'stream', maxRedirects: 1 }) - if (regExpPrivateIP.test(getRes.request.socket.remoteAddress)) { + if (!options?.allowPrivateIP && regExpPrivateIP.test(getRes.request.socket.remoteAddress)) { // close request getRes.data?.destroy() // Prevent SSRF attacks and perform a DNS-rebinding check if a HEAD request has already been made
a2f86e17654brefactor(backend:files): extract downloadFile and centralize SSRF, content-length, and quota checks
5 files changed · +97 −68
backend/src/applications/files/services/files-manager.service.spec.ts+18 −2 modified@@ -6,6 +6,7 @@ import fs from 'node:fs' import path from 'node:path' import { PassThrough, Readable } from 'node:stream' import * as tar from 'tar' +import { transformAndValidate } from '../../../common/functions' import * as imageUtils from '../../../common/image' import { ContextManager } from '../../../infrastructure/context/services/context-manager.service' import { NotificationsManager } from '../../notifications/services/notifications-manager.service' @@ -15,6 +16,7 @@ import * as spacesPermsUtils from '../../spaces/utils/permissions' import { DEPTH } from '../../webdav/constants/webdav' import { ACTION } from '../../../common/constants' import { FILE_OPERATION } from '../constants/operations' +import { DownloadFileDto } from '../dto/file-operations.dto' import { FileEvent, FileTaskEvent } from '../events/file-events' import { FileError } from '../models/file-error' import { LockConflict } from '../models/file-lock-error' @@ -350,11 +352,25 @@ describe(FilesManager.name, () => { expect(filesQueries.deleteFiles).toHaveBeenCalledWith(space.dbFile, false, true) }) + describe('downloadFromUrl dto validation', () => { + it('should accept http and https schemes', () => { + expect(transformAndValidate(DownloadFileDto, { url: 'https://example.org/file.txt' }).url).toBe('https://example.org/file.txt') + expect(transformAndValidate(DownloadFileDto, { url: 'http://example.org/file.txt' }).url).toBe('http://example.org/file.txt') + }) + + it('should reject non-http(s) schemes', () => { + const invalidUrls = ['ftp://example.org/file.txt', 'file:///tmp/file.txt', 'ws://example.org/file.txt'] + for (const url of invalidUrls) { + expect(() => transformAndValidate(DownloadFileDto, { url })).toThrow() + } + }) + }) + it('downloadFromUrl should throw conflict when lock cannot be created', async () => { const space = makeSpace() filesLockManager.create.mockResolvedValueOnce([false, { key: 'other', owner: { id: 99 } }]) - await expect(service.downloadFromUrl(user, space, 'https://example.org/file.txt')).rejects.toBeInstanceOf(LockConflict) + await expect(service.downloadFromUrl(user, space, { url: 'https://example.org/file.txt' })).rejects.toBeInstanceOf(LockConflict) }) it('downloadFromUrl should handle HEAD+GET and emit task watch/event', async () => { @@ -372,7 +388,7 @@ describe(FilesManager.name, () => { const taskEmitSpy = jest.spyOn(FileTaskEvent, 'emit') const fileEmitSpy = jest.spyOn(FileEvent, 'emit') - await service.downloadFromUrl(user, space, 'https://example.org/file.txt') + await service.downloadFromUrl(user, space, { url: 'https://example.org/file.txt' }) expect(space.task.props.totalSize).toBe(55) expect(taskEmitSpy).toHaveBeenCalledWith('startWatch', space, FILE_OPERATION.DOWNLOAD, '/tmp/download.txt')
backend/src/applications/files/services/files-manager.service.ts+7 −41 modified@@ -1,7 +1,6 @@ import { HttpService } from '@nestjs/axios' import { HttpStatus, Injectable, Logger } from '@nestjs/common' import archiver, { Archiver } from 'archiver' -import { AxiosResponse } from 'axios' import fs from 'node:fs' import path from 'node:path' import { Readable } from 'node:stream' @@ -27,7 +26,7 @@ import { TAR_EXTENSION, TAR_GZ_EXTENSION } from '../constants/compress' import { COMPRESSION_EXTENSION, DEFAULT_HIGH_WATER_MARK } from '../constants/files' import { FILE_OPERATION } from '../constants/operations' import { DOCUMENT_TYPE, SAMPLE_PATH_WITHOUT_EXT } from '../constants/samples' -import { CompressFileDto } from '../dto/file-operations.dto' +import { CompressFileDto, DownloadFileDto } from '../dto/file-operations.dto' import { FileDBProps } from '../interfaces/file-db-props.interface' import { FileLock } from '../interfaces/file-lock.interface' import { FileLockProps } from '../interfaces/file-props.interface' @@ -56,7 +55,7 @@ import { } from '../utils/files' import { SendFile } from '../utils/send-file' import { extractZip } from '../utils/unzip-file' -import { regExpPrivateIP } from '../utils/url-file' +import { downloadFile } from '../utils/download-file' import { FilesLockManager } from './files-lock-manager.service' import { FilesQueries } from './files-queries.service' import { FileEvent, FileTaskEvent } from '../events/file-events' @@ -503,54 +502,21 @@ export class FilesManager { await this.filesQueries.deleteFiles(space.dbFile, isDir, forceDeleteInDB) } - async downloadFromUrl(user: UserModel, space: SpaceEnv, url: string): Promise<void> { - this.logger.log({ tag: this.downloadFromUrl.name, msg: `${url}` }) - // create lock + async downloadFromUrl(user: UserModel, space: SpaceEnv, downloadDto: DownloadFileDto): Promise<void> { + this.logger.log({ tag: this.downloadFromUrl.name, msg: `${downloadDto.url}` }) const rPath = await uniqueFilePathFromDir(space.realPath) const dbFile = space.dbFile dbFile.path = path.join(dirName(dbFile.path), fileName(space.realPath)) + + // create lock const [ok, fileLock] = await this.filesLockManager.create(user, dbFile, SERVER_NAME, DEPTH.RESOURCE) if (!ok) { throw new LockConflict(fileLock, 'Conflicting lock') } - // tasking - if (space.task.cacheKey) { - let headRes: AxiosResponse - - try { - headRes = await this.http.axiosRef({ method: HTTP_METHOD.HEAD, url: url, maxRedirects: 1 }) - } catch (e) { - // release lock - await this.filesLockManager.removeLock(fileLock.key) - this.logger.error({ tag: this.downloadFromUrl.name, msg: `${url} : ${e}` }) - throw new FileError(HttpStatus.BAD_REQUEST, 'Unable to download file') - } - if (regExpPrivateIP.test(headRes.request.socket.remoteAddress)) { - // release lock - await this.filesLockManager.removeLock(fileLock.key) - // prevent SSRF attack - throw new FileError(HttpStatus.FORBIDDEN, 'Access to internal IP addresses is forbidden') - } - - // attempt to retrieve the Content-Length header - try { - if ('content-length' in headRes.headers) { - space.task.props.totalSize = Number(headRes.headers['content-length']) || null - } - } catch (e) { - this.logger.debug({ tag: this.downloadFromUrl.name, msg: `content-length : ${e}` }) - } - FileTaskEvent.emit('startWatch', space, FILE_OPERATION.DOWNLOAD, rPath) - } // do try { - const getRes = await this.http.axiosRef({ method: HTTP_METHOD.GET, url: url, responseType: 'stream', maxRedirects: 1 }) - if (regExpPrivateIP.test(getRes.request.socket.remoteAddress)) { - // Prevent SSRF attacks and perform a DNS-rebinding check if a HEAD request has already been made - throw new FileError(HttpStatus.FORBIDDEN, 'Access to internal IP addresses is forbidden') - } - await writeFromStream(rPath, getRes.data) + await downloadFile(this.http, downloadDto, rPath, space) } finally { // release lock await this.filesLockManager.removeLock(fileLock.key)
backend/src/applications/files/services/files-methods.service.ts+1 −1 modified@@ -87,7 +87,7 @@ export class FilesMethods { async downloadFromUrl(user: UserModel, space: SpaceEnv, downloadDto: DownloadFileDto): Promise<void> { checkFileName(space.realPath) try { - return await this.filesManager.downloadFromUrl(user, space, downloadDto.url) + return await this.filesManager.downloadFromUrl(user, space, downloadDto) } catch (e) { this.handleError(space, FILE_OPERATION.DOWNLOAD, e) }
backend/src/applications/files/utils/download-file.ts+71 −0 added@@ -0,0 +1,71 @@ +import type { HttpService } from '@nestjs/axios' +import type { SpaceEnv } from '../../spaces/models/space-env.model' +import type { AxiosResponse } from 'axios' +import { HTTP_METHOD } from '../../applications.constants' +import { FileError } from '../models/file-error' +import { HttpStatus } from '@nestjs/common' +import { FileTaskEvent } from '../events/file-events' +import { FILE_OPERATION } from '../constants/operations' +import { writeFromStream } from './files' +import { DownloadFileDto } from '../dto/file-operations.dto' + +const parts = [ + // IPv4 loopback (127.0.0.0/8) + '127\\.(?:\\d{1,3}\\.){2}\\d{1,3}', + // IPv4 link-local (169.254.0.0/16) + '169\\.254\\.\\d{1,3}\\.\\d{1,3}', + // IPv4 Carrier-grade NAT (100.64.0.0/10) + '100\\.(?:6[4-9]|[7-9]\\d|1[01]\\d|12[0-7])\\.\\d{1,3}\\.\\d{1,3}', + // IPv4 private (10.0.0.0/8) + '10\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}', + // IPv4 private (192.168.0.0/16) + '192\\.168\\.\\d{1,3}\\.\\d{1,3}', + // IPv4 private (172.16.0.0/12) + '172\\.(?:1[6-9]|2\\d|3[0-1])\\.\\d{1,3}\\.\\d{1,3}', + // IPv4 & IPv6 loopback + '::1', + '::', + '0.0.0.0', + // IPv6 Unique Local Address (fc00::/7) + 'f[cd][0-9a-f]{2}:[0-9a-f:]+', + // IPv6 link-local (fe80::/10) + 'fe[89ab][0-9a-f]{2}:[0-9a-f:]+' +] + +const regExpPrivateIP = new RegExp(`^(?:${parts.join('|')})$`, 'i') +const errorRegexpPrivateIP = 'Access to internal IP addresses is forbidden' + +export async function downloadFile(http: HttpService, downloadDto: DownloadFileDto, dstPath: string, space?: SpaceEnv) { + // dto must be validated by the caller + const headRes: AxiosResponse = await http.axiosRef({ method: HTTP_METHOD.HEAD, url: downloadDto.url, maxRedirects: 1 }) + if (regExpPrivateIP.test(headRes.request.socket.remoteAddress)) { + // prevent SSRF attack + throw new FileError(HttpStatus.FORBIDDEN, errorRegexpPrivateIP) + } + + // attempt to retrieve the Content-Length header + const contentLength = 'content-length' in headRes.headers ? Number(headRes.headers['content-length']) || null : null + if (!contentLength) { + throw new FileError(HttpStatus.BAD_REQUEST, 'Missing "content-length" header') + } + + if (space) { + if (space.willExceedQuota(contentLength)) { + throw new FileError(HttpStatus.INSUFFICIENT_STORAGE, 'Storage quota will be exceeded') + } + // tasking + if (space.task.cacheKey) { + space.task.props.totalSize = contentLength + FileTaskEvent.emit('startWatch', space, FILE_OPERATION.DOWNLOAD, dstPath) + } + } + + const getRes = await http.axiosRef({ method: HTTP_METHOD.GET, url: downloadDto.url, responseType: 'stream', maxRedirects: 1 }) + if (regExpPrivateIP.test(getRes.request.socket.remoteAddress)) { + // close request + getRes.data?.destroy() + // Prevent SSRF attacks and perform a DNS-rebinding check if a HEAD request has already been made + throw new FileError(HttpStatus.FORBIDDEN, errorRegexpPrivateIP) + } + await writeFromStream(dstPath, getRes.data) +}
backend/src/applications/files/utils/url-file.ts+0 −24 removed@@ -1,24 +0,0 @@ -const parts = [ - // IPv4 loopback (127.0.0.0/8) - '127\\.(?:\\d{1,3}\\.){2}\\d{1,3}', - // IPv4 link-local (169.254.0.0/16) - '169\\.254\\.\\d{1,3}\\.\\d{1,3}', - // IPv4 Carrier-grade NAT (100.64.0.0/10) - '100\\.(?:6[4-9]|[7-9]\\d|1[01]\\d|12[0-7])\\.\\d{1,3}\\.\\d{1,3}', - // IPv4 private (10.0.0.0/8) - '10\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}', - // IPv4 private (192.168.0.0/16) - '192\\.168\\.\\d{1,3}\\.\\d{1,3}', - // IPv4 private (172.16.0.0/12) - '172\\.(?:1[6-9]|2\\d|3[0-1])\\.\\d{1,3}\\.\\d{1,3}', - // IPv4 & IPv6 loopback - '::1', - '::', - '0.0.0.0', - // IPv6 Unique Local Address (fc00::/7) - 'f[cd][0-9a-f]{2}:[0-9a-f:]+', - // IPv6 link-local (fe80::/10) - 'fe[89ab][0-9a-f]{2}:[0-9a-f:]+' -] - -export const regExpPrivateIP = new RegExp(`^(?:${parts.join('|')})$`, 'i')
325df7b1d897chore(deps): update
1 file changed · +426 −447
package-lock.json+426 −447 modified@@ -491,36 +491,6 @@ } } }, - "node_modules/@angular-devkit/architect/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@angular-devkit/architect/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/architect/node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -683,22 +653,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@angular-devkit/schematics-cli/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/schematics-cli/node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -822,20 +776,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@angular-devkit/schematics-cli/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/schematics-cli/node_modules/restore-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", @@ -928,36 +868,6 @@ } } }, - "node_modules/@angular-devkit/schematics/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@angular-devkit/schematics/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/schematics/node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -1011,36 +921,6 @@ } } }, - "node_modules/@angular-eslint/builder/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@angular-eslint/builder/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-eslint/builder/node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -1139,36 +1019,6 @@ } } }, - "node_modules/@angular-eslint/schematics/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@angular-eslint/schematics/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-eslint/schematics/node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -1453,22 +1303,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@angular/cli/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular/cli/node_modules/cliui": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", @@ -1501,20 +1335,6 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/@angular/cli/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular/cli/node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -2937,9 +2757,9 @@ } }, "node_modules/@codemirror/search": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz", - "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==", + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz", + "integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", @@ -3016,9 +2836,9 @@ } }, "node_modules/@dependents/detective-less": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@dependents/detective-less/-/detective-less-5.0.1.tgz", - "integrity": "sha512-Y6+WUMsTFWE5jb20IFP4YGa5IrGY/+a/FbOSjDF/wz9gepU2hwCYSXRHP/vPwBvwcY3SVMASt4yXxbXNXigmZQ==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@dependents/detective-less/-/detective-less-5.0.2.tgz", + "integrity": "sha512-QPKO4ao2+iniYAYnPZwHKK67EgDG2GAdye9OCy11xsmApHGwzpH3AcSdPjGyPO3tC2/K8mF7JjWX3A/FTRnskg==", "dev": true, "license": "MIT", "dependencies": { @@ -4018,9 +3838,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -4394,9 +4214,9 @@ } }, "node_modules/@fastify/static": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.1.1.tgz", - "integrity": "sha512-LHxFea3qdwe0Pbbkh/yux7/k6nFNLGTNcbLKVYgmRDB6LdDE/8TFSO7qWZ0IzM/nF6iwR8W03oFlwe4v79R1Ow==", + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.1.3.tgz", + "integrity": "sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==", "funding": [ { "type": "github", @@ -7154,14 +6974,253 @@ ] }, "node_modules/@napi-rs/canvas": { - "name": "noop3", - "version": "1000.0.0", - "resolved": "https://registry.npmjs.org/noop3/-/noop3-1000.0.0.tgz", - "integrity": "sha512-OkcS5jjmDBhdLt5rwoHtItQfUHP3+NpWA9voB0SHth+KLIoupZEe+3D76bNvS37qG8vg2FFSqQ61blrEtZj1Yw==", + "version": "0.1.99", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.99.tgz", + "integrity": "sha512-zN4eQlK3eBf7aJBcTHZilpBH3tDekBzPMIWC8r0s94Ecl73XfOyFi4w7yKFMRVUT0lvNQjtOL8YSrwqQj6mZFg==", "license": "MIT", "optional": true, + "workspaces": [ + "e2e/*" + ], "engines": { - "node": ">=6" + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.99", + "@napi-rs/canvas-darwin-arm64": "0.1.99", + "@napi-rs/canvas-darwin-x64": "0.1.99", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.99", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.99", + "@napi-rs/canvas-linux-arm64-musl": "0.1.99", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.99", + "@napi-rs/canvas-linux-x64-gnu": "0.1.99", + "@napi-rs/canvas-linux-x64-musl": "0.1.99", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.99", + "@napi-rs/canvas-win32-x64-msvc": "0.1.99" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.99", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.99.tgz", + "integrity": "sha512-9OCRt8VVxA17m32NWZKyNC2qamdaS/SC5CEOIQwFngRq0DIeVm4PDal+6Ljnhqm2whZiC63DNuKZ4xSp2nbj9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.99", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.99.tgz", + "integrity": "sha512-lupMDMy1+H38dhyCcLirOKKVUyzzlxi7j7rGPLI3vViMHOoPjcXO1b10ivy+ad+q6MiwHfoLjKTCoLke5ySOBg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.99", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.99.tgz", + "integrity": "sha512-fdz02t4w8n6Ii/rYhWig6STb/zcTmCC/6YZTGmjoDeidDwn9Wf0ukQVynhCPEs29vqUc66wHZKsuIgMs9tycCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.99", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.99.tgz", + "integrity": "sha512-w4FwVwlNo00ezeRhfY62IVIyt6G3u8wodkPtiqWc52BUHx+VDBUM2vkS3ogfANaLI7hnf3s6WK4LyZVUjBg1lA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.99", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.99.tgz", + "integrity": "sha512-8JvHeexKQ8c7g0q7YJ29NVQwnf1ePghP9ys9ZN0R0qzyqJQ9Uw6N9qnDINArlm3IYHexB7LjzArIfhQiqSDGvQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.99", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.99.tgz", + "integrity": "sha512-Z+6nyLdJXWzLPVxi4H6g9TJop4DwN3KSgHWto5JCbZV5/uKoVqcSynPs0tGlUHOoWI8S8tEvJspz51GQkvr07w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.99", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.99.tgz", + "integrity": "sha512-jAnfOUv4IO1l8Levk5t85oVtEBOXLa07KnIUgWo1CDlPxiqpxS3uBfiE38Lvj/CQgHaNF6Nxk/SaemwLgsVJgw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.99", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.99.tgz", + "integrity": "sha512-mIkXw3fGmbYyFjSmfWEvty4jN+rwEOmv0+Dy9bRvvTzLYWCgm3RMgUEQVfAKFw96nIRFnyNZiK83KNQaVVFjng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.99", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.99.tgz", + "integrity": "sha512-f3Uz2P0RgrtBHISxZqr6yiYXJlTDyCVBumDacxo+4AmSg7z0HiqYZKGWC/gszq3fbPhyQUya1W2AEteKxT9Y6A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.99", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.99.tgz", + "integrity": "sha512-XE6KUkfqRsCNejcoRMiMr3RaUeObxNf6y7dut3hrq2rn7PzfRTZgrjF1F/B2C7FcdgqY/vSHWpQeMuNz1vTNHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.99", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.99.tgz", + "integrity": "sha512-plMYGVbc/vmmPF9MtmHbwNk1rL1Aj53vQZt+Gnv1oZn6gmd9jEHHJ0n9Nd2nxa5sKH7TS5IjkCDM6289O0d6PQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, "node_modules/@napi-rs/nice": { @@ -8325,22 +8384,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@nestjs/schematics/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@nestjs/schematics/node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -8464,20 +8507,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@nestjs/schematics/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@nestjs/schematics/node_modules/restore-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", @@ -9746,36 +9775,6 @@ } } }, - "node_modules/@schematics/angular/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@schematics/angular/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@schematics/angular/node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -11172,17 +11171,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", - "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz", + "integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.58.2", - "@typescript-eslint/type-utils": "8.58.2", - "@typescript-eslint/utils": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/type-utils": "8.59.0", + "@typescript-eslint/utils": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -11195,22 +11194,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.58.2", + "@typescript-eslint/parser": "^8.59.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz", - "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz", + "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.58.2", - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3" }, "engines": { @@ -11226,14 +11225,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", - "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", + "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.58.2", - "@typescript-eslint/types": "^8.58.2", + "@typescript-eslint/tsconfig-utils": "^8.59.0", + "@typescript-eslint/types": "^8.59.0", "debug": "^4.4.3" }, "engines": { @@ -11248,14 +11247,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", - "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", + "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2" + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -11266,9 +11265,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", - "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", + "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==", "dev": true, "license": "MIT", "engines": { @@ -11283,15 +11282,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", - "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz", + "integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2", - "@typescript-eslint/utils": "8.58.2", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/utils": "8.59.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -11308,9 +11307,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", - "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz", + "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==", "dev": true, "license": "MIT", "engines": { @@ -11322,16 +11321,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", - "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", + "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.58.2", - "@typescript-eslint/tsconfig-utils": "8.58.2", - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2", + "@typescript-eslint/project-service": "8.59.0", + "@typescript-eslint/tsconfig-utils": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -11406,16 +11405,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz", - "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz", + "integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.58.2", - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2" + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -11430,13 +11429,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", - "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", + "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/types": "8.59.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -11785,14 +11784,14 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", - "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz", + "integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==", "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.2", - "@vue/shared": "3.5.32", + "@vue/shared": "3.5.33", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" @@ -11812,31 +11811,31 @@ } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", - "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz", + "integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.32", - "@vue/shared": "3.5.32" + "@vue/compiler-core": "3.5.33", + "@vue/shared": "3.5.33" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", - "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.33.tgz", + "integrity": "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==", "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.2", - "@vue/compiler-core": "3.5.32", - "@vue/compiler-dom": "3.5.32", - "@vue/compiler-ssr": "3.5.32", - "@vue/shared": "3.5.32", + "@vue/compiler-core": "3.5.33", + "@vue/compiler-dom": "3.5.33", + "@vue/compiler-ssr": "3.5.33", + "@vue/shared": "3.5.33", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", - "postcss": "^8.5.8", + "postcss": "^8.5.10", "source-map-js": "^1.2.1" } }, @@ -11851,20 +11850,20 @@ } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", - "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.33.tgz", + "integrity": "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.32", - "@vue/shared": "3.5.32" + "@vue/compiler-dom": "3.5.33", + "@vue/shared": "3.5.33" } }, "node_modules/@vue/shared": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", - "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz", + "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==", "dev": true, "license": "MIT" }, @@ -12437,36 +12436,6 @@ } } }, - "node_modules/angular-eslint/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/angular-eslint/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/angular-eslint/node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -12948,9 +12917,9 @@ } }, "node_modules/axios": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.1.tgz", - "integrity": "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", "license": "MIT", "peer": true, "dependencies": { @@ -13219,9 +13188,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.20", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz", - "integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==", + "version": "2.10.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz", + "integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -13797,9 +13766,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001788", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", - "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "version": "1.0.30001790", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", + "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==", "dev": true, "funding": [ { @@ -15058,9 +15027,9 @@ } }, "node_modules/dependency-tree": { - "version": "11.4.2", - "resolved": "https://registry.npmjs.org/dependency-tree/-/dependency-tree-11.4.2.tgz", - "integrity": "sha512-Nh8VUs4plHIgkjkIvHyZ9voEDfv86aAhToBWi04l4HnulnYIWwXUipQtGgfnF4tadVaINH8HqGcsYlfaWzIs1g==", + "version": "11.4.3", + "resolved": "https://registry.npmjs.org/dependency-tree/-/dependency-tree-11.4.3.tgz", + "integrity": "sha512-Y2gzOJ2Rb2X7MN6pT9llWpXxl5J5s5/11CBpJ5b85DjEqZH7jv3T9RO6HRV/PI/3MDmaKn/g7uoYdYmSb9vLlw==", "dev": true, "license": "MIT", "dependencies": { @@ -15125,9 +15094,9 @@ } }, "node_modules/detective-amd": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/detective-amd/-/detective-amd-6.0.1.tgz", - "integrity": "sha512-TtyZ3OhwUoEEIhTFoc1C9IyJIud3y+xYkSRjmvCt65+ycQuc3VcBrPRTMWoO/AnuCyOB8T5gky+xf7Igxtjd3g==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/detective-amd/-/detective-amd-6.0.2.tgz", + "integrity": "sha512-qX4zkNVcufOoo7pKlRnLHEzUwDcqIY5N9FEuNJN+rDUjct3gikNdVJXRfpI6sG/Y9pfIMjcXeNdHV1oYulxjmw==", "dev": true, "license": "MIT", "dependencies": { @@ -15144,9 +15113,9 @@ } }, "node_modules/detective-cjs": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/detective-cjs/-/detective-cjs-6.1.0.tgz", - "integrity": "sha512-Qt3S4IddVNDb+71lm+jmt5NznIsgcKlibTnrw9Zr91rT9vRwKp+73+ImqLTNrQj4YuOxnzrC7GwIAVwF7136XQ==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/detective-cjs/-/detective-cjs-6.1.1.tgz", + "integrity": "sha512-pSh7mkCKEtLlmANqLu3KDFS3NV8Hx41jy/JF1/gAWOgU+Uo5QTkeI1tWNP4dWGo4L0E9j18Ez9EPsTleautKqA==", "dev": true, "license": "MIT", "dependencies": { @@ -15158,9 +15127,9 @@ } }, "node_modules/detective-es6": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/detective-es6/-/detective-es6-5.0.1.tgz", - "integrity": "sha512-XusTPuewnSUdoxRSx8OOI6xIA/uld/wMQwYsouvFN2LAg7HgP06NF1lHRV3x6BZxyL2Kkoih4ewcq8hcbGtwew==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/detective-es6/-/detective-es6-5.0.2.tgz", + "integrity": "sha512-+qHHGYhjupiVs4rnIpI9nZ5B130A4AmE35ZX1w33hb46vcZ7T3jfDbvmPw0FhWtMHn5BS5HHu7ZtnZ53bMcXZA==", "dev": true, "license": "MIT", "dependencies": { @@ -15226,9 +15195,9 @@ } }, "node_modules/detective-typescript": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/detective-typescript/-/detective-typescript-14.1.1.tgz", - "integrity": "sha512-P0V72pffNrtjHm7kZPiwXeM47l3jF/M3nZ543ZNVWG6sWvYwq95ERoL1+6Txm5Mam8EPVu1gqpHECvoAZsznog==", + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/detective-typescript/-/detective-typescript-14.1.2.tgz", + "integrity": "sha512-bIeEn0eVi/JRsE1YizBR2ilnMlWRAIBJJ6kXCKNFxEEWhUcEY3R6I3KYIAy48ieURbD1hcb3Ebvl8AqeoPMSzg==", "dev": true, "license": "MIT", "dependencies": { @@ -16107,9 +16076,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.340", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", - "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==", + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", "dev": true, "license": "ISC" }, @@ -16248,14 +16217,14 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", + "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" @@ -16595,9 +16564,9 @@ } }, "node_modules/eslint/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -17049,9 +17018,9 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.0.tgz", + "integrity": "sha512-gDK8yiqKxrGta+3WtON59arrrw6GLmadA1qoFgYXzdcch8fmKDID2XqO8itsi3f1wufXYPT51387dN6cvVBS3Q==", "dev": true, "license": "MIT", "dependencies": { @@ -17299,9 +17268,9 @@ } }, "node_modules/fastify": { - "version": "5.8.5", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", - "integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==", + "version": "5.8.4", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.4.tgz", + "integrity": "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==", "funding": [ { "type": "github", @@ -21461,9 +21430,9 @@ "license": "MIT" }, "node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -21651,9 +21620,9 @@ } }, "node_modules/libphonenumber-js": { - "version": "1.12.41", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.41.tgz", - "integrity": "sha512-lsmMmGXBxXIK/VMLEj0kL6MtUs1kBGj1nTCzi6zgQoG1DEwqwt2DQyHxcLykceIxAnfE3hya7NuIh6PpC6S3fA==", + "version": "1.12.42", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.42.tgz", + "integrity": "sha512-oKQFPTibqQwZZkChCDVMFVJXMZdyJNqDWZWYNn8BgyAaK/6yFJEowxCY0RVFirRyWP63hMRuKlkSEd9qlvbWXg==", "license": "MIT" }, "node_modules/light-my-request": { @@ -23188,9 +23157,9 @@ } }, "node_modules/module-definition": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/module-definition/-/module-definition-6.0.1.tgz", - "integrity": "sha512-FeVc50FTfVVQnolk/WQT8MX+2WVcDnTGiq6Wo+/+lJ2ET1bRVi3HG3YlJUfqagNMc/kUlFSoR96AJkxGpKz13g==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/module-definition/-/module-definition-6.0.2.tgz", + "integrity": "sha512-SvAU3lB0+Yjbq55yHY3wkRZBOh+fhU1SnIF3IFbTewv6mtAh7yUT8ACHAJ2mGIJ7tCes2QuCL/cl6m0JSZ/ArA==", "dev": true, "license": "MIT", "dependencies": { @@ -23293,9 +23262,9 @@ } }, "node_modules/mysql2": { - "version": "3.22.1", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.1.tgz", - "integrity": "sha512-48+9UXehKyxxiP2pqCxUq+MSFvX+v41jwsSpFDQO/jAoFuAELutBGJUhWJnDbe82/OBlIhSBMC82WeonmznT/Q==", + "version": "3.22.2", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.2.tgz", + "integrity": "sha512-snC/L6YoCJPFpozZo3p3hiOlt9ItQ7sCnLSziFLlIttEzsPhrdcPT8g21BiQ7Oqif25W4Xq1IFuBzBvoFYDf0Q==", "license": "MIT", "dependencies": { "aws-ssl-profiles": "^1.1.2", @@ -23525,21 +23494,21 @@ } }, "node_modules/node-gyp": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.2.0.tgz", - "integrity": "sha512-q23WdzrQv48KozXlr0U1v9dwO/k59NHeSzn6loGcasyf0UnSrtzs8kRxM+mfwJSf0DkX0s43hcqgnSO4/VNthQ==", + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.3.0.tgz", + "integrity": "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==", "dev": true, "license": "MIT", "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", - "make-fetch-happen": "^15.0.0", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", + "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { @@ -23608,9 +23577,9 @@ "optional": true }, "node_modules/node-releases": { - "version": "2.0.37", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", - "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", "dev": true, "license": "MIT" }, @@ -25965,9 +25934,9 @@ } }, "node_modules/schema-utils/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -27104,9 +27073,9 @@ } }, "node_modules/tapable": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", - "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", "engines": { @@ -27163,9 +27132,9 @@ } }, "node_modules/terser": { - "version": "5.46.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", - "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", + "version": "5.46.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.2.tgz", + "integrity": "sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -27182,9 +27151,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", - "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.5.0.tgz", + "integrity": "sha512-UYhptBwhWvfIjKd/UuFo6D8uq9xpGLDK+z8EDsj/zWhrTaH34cKEbrkMKfV5YWqGBvAYA3tlzZbs2R+qYrbQJA==", "dev": true, "license": "MIT", "dependencies": { @@ -28527,16 +28496,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz", - "integrity": "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.0.tgz", + "integrity": "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.58.2", - "@typescript-eslint/parser": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2", - "@typescript-eslint/utils": "8.58.2" + "@typescript-eslint/eslint-plugin": "8.59.0", + "@typescript-eslint/parser": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/utils": "8.59.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -28608,6 +28577,16 @@ "through": "^2.3.8" } }, + "node_modules/undici": { + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", + "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -29536,9 +29515,9 @@ } }, "node_modules/webpack-sources": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", - "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.4.0.tgz", + "integrity": "sha512-gHwIe1cgBvvfLeu1Yz/dcFpmHfKDVxxyqI+kzqmuxZED81z2ChxpyqPaWcNqigPywhaEke7AjSGga+kxY55gjQ==", "dev": true, "license": "MIT", "engines": {
Vulnerability mechanics
Root cause
"The regular expression used to block private IP addresses does not account for IPv4-mapped IPv6 addresses."
Attack vector
An attacker can provide a crafted URL to the file download feature. This URL can point to an internal IP address represented in IPv4-mapped IPv6 format, such as ::ffff:127.0.0.1. The server, when checking the remote address against the blocklist, will fail to identify this as a private IP due to the regex limitation, allowing the SSRF protection to be bypassed [ref_id=1].
Affected code
The vulnerability lies within the `downloadFromUrl()` function in `backend/src/applications/files/services/files-manager.service.ts`. This function checks the `request.socket.remoteAddress` against the `regExpPrivateIP` defined in `backend/src/applications/files/utils/url-file.ts`. The regex in `url-file.ts` was insufficient as it did not include IPv4-mapped IPv6 variants [ref_id=1].
What the fix does
The patch updates the `regExpPrivateIP` regex to include IPv4-mapped IPv6 addresses. This ensures that addresses like `::ffff:127.0.0.1` and other private IPv4-mapped IPv6 formats are correctly identified and blocked by the URL download feature, preventing SSRF attacks [patch_id=4936408].
Preconditions
- inputThe attacker must be able to provide a URL to the file download feature.
- networkThe system must be a dual-stack system where Node.js can report IPv4-mapped IPv6 addresses.
Reproduction
The bundle includes a PDF file named 'poc.pdf' which likely contains reproduction steps or evidence.
Generated on Jun 5, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
0No linked articles in our index yet.