VYPR
High severity7.7GHSA Advisory· Published Jun 5, 2026· Updated Jun 5, 2026

Sync-in Server: SSRF protection bypass via IPv4-mapped IPv6 addresses in regExpPrivateIP

CVE-2026-47684

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

1

Patches

4
22e773e5b826

fix(backend:files): harden remote downloads against SSRF, redirects, proxy bypasses and oversized streams

https://github.com/Sync-in/serverjohavenMay 18, 2026Fixed in 2.3.0via ghsa-release-walk
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",
    
44261ea20e83

feat(backend:files): support trusted private IP downloads

https://github.com/Sync-in/serverjohavenMay 9, 2026Fixed in 2.3.0via ghsa-release-walk
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
    
a2f86e17654b

refactor(backend:files): extract downloadFile and centralize SSRF, content-length, and quota checks

https://github.com/Sync-in/serverjohavenApr 23, 2026Fixed in 2.3.0via ghsa-release-walk
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')
    
325df7b1d897

chore(deps): update

https://github.com/Sync-in/serverjohavenApr 23, 2026Fixed in 2.3.0via ghsa-release-walk
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

3

News mentions

0

No linked articles in our index yet.