From e86919fb9a750f7531682f75a1f7a2db573e47ee Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Wed, 19 Mar 2025 15:41:05 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F(y-provider)=20manage=20au?= =?UTF-8?q?th=20in=20y-provider=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The way to connect to the hocuspocus server needs to be proxified in nginx to query a dedicated route in the django application and then follow the request to the express server with the additionnal headers. The auth can be done in the express server by querying the backend on the document retrieve endpoint. If the response status code is 200, the user has access to the document, otherwise it is not the case. Then we can check the abilities to determine what the user can do or not. --- docker-compose.yml | 4 + docker/files/etc/nginx/conf.d/default.conf | 48 ----- env.d/development/common.dist | 5 +- .../e2e/__tests__/app-impress/config.spec.ts | 6 +- .../__tests__/app-impress/doc-editor.spec.ts | 6 +- .../y-provider/__tests__/hocusPocusWS.test.ts | 198 ++++++++++++++---- src/frontend/servers/y-provider/package.json | 1 + .../servers/y-provider/src/api/getDoc.ts | 76 +++++++ .../servers/y-provider/src/api/getMe.ts | 31 +++ src/frontend/servers/y-provider/src/env.ts | 2 + .../collaborationResetConnectionsHandler.ts | 13 +- .../servers/y-provider/src/middlewares.ts | 11 +- .../src/servers/hocusPocusServer.ts | 71 +++++-- src/frontend/yarn.lock | 14 ++ 14 files changed, 358 insertions(+), 128 deletions(-) create mode 100644 src/frontend/servers/y-provider/src/api/getDoc.ts create mode 100644 src/frontend/servers/y-provider/src/api/getMe.ts diff --git a/docker-compose.yml b/docker-compose.yml index 01d3b06c..03897a5a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -185,11 +185,15 @@ services: context: . dockerfile: ./src/frontend/servers/y-provider/Dockerfile target: y-provider + command: ["yarn", "workspace", "server-y-provider", "run", "dev"] + working_dir: /app/frontend restart: unless-stopped env_file: - env.d/development/common ports: - "4444:4444" + volumes: + - ./src/frontend/:/app/frontend kc_postgresql: image: postgres:14.3 diff --git a/docker/files/etc/nginx/conf.d/default.conf b/docker/files/etc/nginx/conf.d/default.conf index 66bdada1..072fdc28 100644 --- a/docker/files/etc/nginx/conf.d/default.conf +++ b/docker/files/etc/nginx/conf.d/default.conf @@ -4,54 +4,6 @@ server { server_name localhost; charset utf-8; - # Proxy auth for collaboration server - location /collaboration/ws/ { - # Collaboration Auth request configuration - auth_request /collaboration-auth; - auth_request_set $authHeader $upstream_http_authorization; - auth_request_set $canEdit $upstream_http_x_can_edit; - auth_request_set $userId $upstream_http_x_user_id; - - # Pass specific headers from the auth response - proxy_set_header Authorization $authHeader; - proxy_set_header X-Can-Edit $canEdit; - proxy_set_header X-User-Id $userId; - - # Ensure WebSocket upgrade - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "Upgrade"; - - # Collaboration server - proxy_pass http://y-provider:4444; - - # Set appropriate timeout for WebSocket - proxy_read_timeout 86400; - proxy_send_timeout 86400; - - # Preserve original host and additional headers - proxy_set_header Host $host; - } - - location /collaboration-auth { - proxy_pass http://app-dev:8000/api/v1.0/documents/collaboration-auth/; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Original-URL $request_uri; - - # Prevent the body from being passed - proxy_pass_request_body off; - proxy_set_header Content-Length ""; - proxy_set_header X-Original-Method $request_method; - } - - location /collaboration/api/ { - # Collaboration server - proxy_pass http://y-provider:4444; - proxy_set_header Host $host; - } - # Proxy auth for media location /media/ { # Auth request configuration diff --git a/env.d/development/common.dist b/env.d/development/common.dist index e5872514..0ce3a53e 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -55,10 +55,11 @@ AI_API_KEY=password AI_MODEL=llama # Collaboration -COLLABORATION_API_URL=http://nginx:8083/collaboration/api/ +COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/ +COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000 COLLABORATION_SERVER_ORIGIN=http://localhost:3000 COLLABORATION_SERVER_SECRET=my-secret -COLLABORATION_WS_URL=ws://localhost:8083/collaboration/ws/ +COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/ # Frontend FRONTEND_THEME=default diff --git a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts index 29a84a70..43896ec1 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts @@ -6,7 +6,7 @@ import { createDoc, verifyDocName } from './common'; const config = { CRISP_WEBSITE_ID: null, - COLLABORATION_WS_URL: 'ws://localhost:8083/collaboration/ws/', + COLLABORATION_WS_URL: 'ws://localhost:4444/collaboration/ws/', ENVIRONMENT: 'development', FRONTEND_THEME: 'default', MEDIA_BASE_URL: 'http://localhost:8083', @@ -99,7 +99,7 @@ test.describe('Config', () => { browserName, }) => { const webSocketPromise = page.waitForEvent('websocket', (webSocket) => { - return webSocket.url().includes('ws://localhost:8083/collaboration/ws/'); + return webSocket.url().includes('ws://localhost:4444/collaboration/ws/'); }); await page.goto('/'); @@ -114,7 +114,7 @@ test.describe('Config', () => { await verifyDocName(page, randomDoc[0]); const webSocket = await webSocketPromise; - expect(webSocket.url()).toContain('ws://localhost:8083/collaboration/ws/'); + expect(webSocket.url()).toContain('ws://localhost:4444/collaboration/ws/'); }); test('it checks that Crisp is trying to init from config endpoint', async ({ diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index 4efdd71e..d451cd69 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -65,7 +65,7 @@ test.describe('Doc Editor', () => { let webSocketPromise = page.waitForEvent('websocket', (webSocket) => { return webSocket .url() - .includes('ws://localhost:8083/collaboration/ws/?room='); + .includes('ws://localhost:4444/collaboration/ws/?room='); }); const randomDoc = await createDoc(page, 'doc-editor', browserName, 1); @@ -73,7 +73,7 @@ test.describe('Doc Editor', () => { let webSocket = await webSocketPromise; expect(webSocket.url()).toContain( - 'ws://localhost:8083/collaboration/ws/?room=', + 'ws://localhost:4444/collaboration/ws/?room=', ); // Is connected @@ -103,7 +103,7 @@ test.describe('Doc Editor', () => { webSocketPromise = page.waitForEvent('websocket', (webSocket) => { return webSocket .url() - .includes('ws://localhost:8083/collaboration/ws/?room='); + .includes('ws://localhost:4444/collaboration/ws/?room='); }); webSocket = await webSocketPromise; diff --git a/src/frontend/servers/y-provider/__tests__/hocusPocusWS.test.ts b/src/frontend/servers/y-provider/__tests__/hocusPocusWS.test.ts index 6499c31b..1ee8f9ac 100644 --- a/src/frontend/servers/y-provider/__tests__/hocusPocusWS.test.ts +++ b/src/frontend/servers/y-provider/__tests__/hocusPocusWS.test.ts @@ -13,11 +13,22 @@ jest.mock('../src/env', () => { PORT: port, COLLABORATION_SERVER_ORIGIN: origin, COLLABORATION_SERVER_SECRET: 'test-secret-api-key', + COLLABORATION_BACKEND_BASE_URL: 'http://app-dev:8000', }; }); console.error = jest.fn(); +const mockDocFetch = jest.fn(); +jest.mock('@/api/getDoc', () => ({ + fetchDocument: mockDocFetch, +})); + +const mockGetMe = jest.fn(); +jest.mock('@/api/getMe', () => ({ + getMe: mockGetMe, +})); + import { hocusPocusServer } from '@/servers/hocusPocusServer'; import { promiseDone } from '../src/helpers'; @@ -30,40 +41,15 @@ describe('Server Tests', () => { await hocusPocusServer.configure({ port: portWS }).listen(); }); + afterEach(() => { + jest.clearAllMocks(); + }); + afterAll(() => { server.close(); void hocusPocusServer.destroy(); }); - test('WebSocket connection with correct API key can connect', () => { - const { promise, done } = promiseDone(); - - // eslint-disable-next-line jest/unbound-method - const { handleConnection } = hocusPocusServer; - const mockHandleConnection = jest.fn(); - (hocusPocusServer.handleConnection as jest.Mock) = mockHandleConnection; - - const clientWS = new WebSocket( - `ws://localhost:${port}/collaboration/ws/?room=test-room`, - { - headers: { - authorization: 'test-secret-api-key', - Origin: origin, - }, - }, - ); - - clientWS.on('open', () => { - expect(mockHandleConnection).toHaveBeenCalled(); - clientWS.close(); - mockHandleConnection.mockClear(); - hocusPocusServer.handleConnection = handleConnection; - done(); - }); - - return promise; - }); - test('WebSocket connection with bad origin should be closed', () => { const { promise, done } = promiseDone(); @@ -84,13 +70,13 @@ describe('Server Tests', () => { return promise; }); - test('WebSocket connection with incorrect API key should be closed', () => { + test('WebSocket connection without cookies header should be closed', () => { const { promise, done } = promiseDone(); + const ws = new WebSocket( `ws://localhost:${port}/collaboration/ws/?room=test-room`, { headers: { - Authorization: 'wrong-api-key', Origin: origin, }, }, @@ -121,6 +107,12 @@ describe('Server Tests', () => { quiet: true, preserveConnection: false, onClose: (data) => { + expect(console.error).toHaveBeenCalledWith( + 'Invalid room name - Probable hacking attempt:', + 'hocuspocus-test', + 'my-test', + ); + wsHocus.stopConnectionAttempt(); expect(data.event.reason).toBe('Forbidden'); wsHocus.webSocket?.close(); @@ -134,9 +126,142 @@ describe('Server Tests', () => { return promise; }); - test('WebSocket connection read-only', () => { + test('WebSocket connection fails if user can not access document', () => { const { promise, done } = promiseDone(); + mockDocFetch.mockRejectedValue(''); + + const wsHocus = new HocuspocusProviderWebsocket({ + url: `ws://localhost:${portWS}/?room=my-test`, + WebSocketPolyfill: WebSocket, + maxAttempts: 1, + quiet: true, + }); + + const provider = new HocuspocusProvider({ + websocketProvider: wsHocus, + name: 'my-test', + broadcast: false, + quiet: true, + preserveConnection: false, + onClose: (data) => { + expect(console.error).toHaveBeenCalledWith( + '[onConnect]', + 'Backend error: Unauthorized', + ); + + wsHocus.stopConnectionAttempt(); + expect(data.event.reason).toBe('Forbidden'); + expect(mockDocFetch).toHaveBeenCalledTimes(1); + wsHocus.webSocket?.close(); + wsHocus.disconnect(); + provider.destroy(); + wsHocus.destroy(); + done(); + }, + }); + + return promise; + }); + + test('WebSocket connection fails if user do not have correct retrieve ability', () => { + const { promise, done } = promiseDone(); + + mockDocFetch.mockResolvedValue({ + abilities: { + retrieve: false, + }, + }); + + const wsHocus = new HocuspocusProviderWebsocket({ + url: `ws://localhost:${portWS}/?room=my-test`, + WebSocketPolyfill: WebSocket, + maxAttempts: 1, + quiet: true, + }); + + const provider = new HocuspocusProvider({ + websocketProvider: wsHocus, + name: 'my-test', + broadcast: false, + quiet: true, + preserveConnection: false, + onClose: (data) => { + expect(console.error).toHaveBeenCalledWith( + 'onConnect: Unauthorized to retrieve this document', + 'my-test', + ); + + wsHocus.stopConnectionAttempt(); + expect(data.event.reason).toBe('Forbidden'); + expect(mockDocFetch).toHaveBeenCalledTimes(1); + wsHocus.webSocket?.close(); + wsHocus.disconnect(); + provider.destroy(); + wsHocus.destroy(); + done(); + }, + }); + + return promise; + }); + + [true, false].forEach((canEdit) => { + test(`WebSocket connection ${canEdit ? 'can' : 'can not'} edit document`, () => { + const { promise, done } = promiseDone(); + + mockDocFetch.mockResolvedValue({ + abilities: { + retrieve: true, + update: canEdit, + }, + }); + + const wsHocus = new HocuspocusProviderWebsocket({ + url: `ws://localhost:${portWS}/?room=hocuspocus-test`, + WebSocketPolyfill: WebSocket, + }); + + const provider = new HocuspocusProvider({ + websocketProvider: wsHocus, + name: 'hocuspocus-test', + broadcast: false, + quiet: true, + onConnect: () => { + void hocusPocusServer + .openDirectConnection('hocuspocus-test') + .then((connection) => { + connection.document?.getConnections().forEach((connection) => { + expect(connection.readOnly).toBe(!canEdit); + }); + + void connection.disconnect(); + + provider.destroy(); + wsHocus.destroy(); + done(); + }); + }, + }); + + return promise; + }); + }); + + test('Add request header x-user-id if found', () => { + const { promise, done } = promiseDone(); + + mockDocFetch.mockResolvedValue({ + abilities: { + retrieve: true, + update: true, + }, + }); + + mockGetMe.mockResolvedValue({ + id: 'test-user-id', + }); + const wsHocus = new HocuspocusProviderWebsocket({ url: `ws://localhost:${portWS}/?room=hocuspocus-test`, WebSocketPolyfill: WebSocket, @@ -152,15 +277,14 @@ describe('Server Tests', () => { .openDirectConnection('hocuspocus-test') .then((connection) => { connection.document?.getConnections().forEach((connection) => { - expect(connection.readOnly).toBe(true); + expect(connection.context.userId).toBe('test-user-id'); }); void connection.disconnect(); + provider.destroy(); + wsHocus.destroy(); + done(); }); - - provider.destroy(); - wsHocus.destroy(); - done(); }, }); diff --git a/src/frontend/servers/y-provider/package.json b/src/frontend/servers/y-provider/package.json index 3ec776cd..415726e0 100644 --- a/src/frontend/servers/y-provider/package.json +++ b/src/frontend/servers/y-provider/package.json @@ -20,6 +20,7 @@ "@hocuspocus/server": "2.15.2", "@sentry/node": "9.3.0", "@sentry/profiling-node": "9.3.0", + "axios": "1.8.2", "cors": "2.8.5", "express": "4.21.2", "express-ws": "5.0.2", diff --git a/src/frontend/servers/y-provider/src/api/getDoc.ts b/src/frontend/servers/y-provider/src/api/getDoc.ts new file mode 100644 index 00000000..6c712f6d --- /dev/null +++ b/src/frontend/servers/y-provider/src/api/getDoc.ts @@ -0,0 +1,76 @@ +import { IncomingHttpHeaders } from 'http'; + +import axios from 'axios'; + +import { COLLABORATION_BACKEND_BASE_URL } from '@/env'; + +enum LinkReach { + RESTRICTED = 'restricted', + PUBLIC = 'public', + AUTHENTICATED = 'authenticated', +} + +enum LinkRole { + READER = 'reader', + EDITOR = 'editor', +} + +type Base64 = string; + +interface Doc { + id: string; + title?: string; + content: Base64; + creator: string; + is_favorite: boolean; + link_reach: LinkReach; + link_role: LinkRole; + nb_accesses_ancestors: number; + nb_accesses_direct: number; + created_at: string; + updated_at: string; + abilities: { + accesses_manage: boolean; + accesses_view: boolean; + ai_transform: boolean; + ai_translate: boolean; + attachment_upload: boolean; + children_create: boolean; + children_list: boolean; + collaboration_auth: boolean; + destroy: boolean; + favorite: boolean; + invite_owner: boolean; + link_configuration: boolean; + media_auth: boolean; + move: boolean; + partial_update: boolean; + restore: boolean; + retrieve: boolean; + update: boolean; + versions_destroy: boolean; + versions_list: boolean; + versions_retrieve: boolean; + }; +} + +export const fetchDocument = async ( + documentName: string, + requestHeaders: IncomingHttpHeaders, +) => { + const response = await axios.get( + `${COLLABORATION_BACKEND_BASE_URL}/api/v1.0/documents/${documentName}/`, + { + headers: { + Cookie: requestHeaders['cookie'], + Origin: requestHeaders['origin'], + }, + }, + ); + + if (response.status !== 200) { + throw new Error(`Failed to fetch document: ${response.statusText}`); + } + + return response.data; +}; diff --git a/src/frontend/servers/y-provider/src/api/getMe.ts b/src/frontend/servers/y-provider/src/api/getMe.ts new file mode 100644 index 00000000..910380d5 --- /dev/null +++ b/src/frontend/servers/y-provider/src/api/getMe.ts @@ -0,0 +1,31 @@ +import { IncomingHttpHeaders } from 'http'; + +import axios from 'axios'; + +import { COLLABORATION_BACKEND_BASE_URL } from '@/env'; + +export interface User { + id: string; + email: string; + full_name: string; + short_name: string; + language: string; +} + +export const getMe = async (requestHeaders: IncomingHttpHeaders) => { + const response = await axios.get( + `${COLLABORATION_BACKEND_BASE_URL}/api/v1.0/users/me/`, + { + headers: { + Cookie: requestHeaders['cookie'], + Origin: requestHeaders['origin'], + }, + }, + ); + + if (response.status !== 200) { + throw new Error(`Failed to fetch user: ${response.statusText}`); + } + + return response.data; +}; diff --git a/src/frontend/servers/y-provider/src/env.ts b/src/frontend/servers/y-provider/src/env.ts index 7f23bfc5..fe281930 100644 --- a/src/frontend/servers/y-provider/src/env.ts +++ b/src/frontend/servers/y-provider/src/env.ts @@ -8,3 +8,5 @@ export const Y_PROVIDER_API_KEY = process.env.Y_PROVIDER_API_KEY || 'yprovider-api-key'; export const PORT = Number(process.env.PORT || 4444); export const SENTRY_DSN = process.env.SENTRY_DSN || ''; +export const COLLABORATION_BACKEND_BASE_URL = + process.env.COLLABORATION_BACKEND_BASE_URL || 'http://app-dev:8000'; diff --git a/src/frontend/servers/y-provider/src/handlers/collaborationResetConnectionsHandler.ts b/src/frontend/servers/y-provider/src/handlers/collaborationResetConnectionsHandler.ts index 4af44096..3e69597f 100644 --- a/src/frontend/servers/y-provider/src/handlers/collaborationResetConnectionsHandler.ts +++ b/src/frontend/servers/y-provider/src/handlers/collaborationResetConnectionsHandler.ts @@ -14,14 +14,7 @@ export const collaborationResetConnectionsHandler = ( const room = req.query.room; const userId = req.headers['x-user-id']; - logger( - 'Resetting connections in room:', - room, - 'for user:', - userId, - 'room:', - room, - ); + logger('Resetting connections in room:', room, 'for user:', userId); if (!room) { res.status(400).json({ error: 'Room name not provided' }); @@ -43,8 +36,8 @@ export const collaborationResetConnectionsHandler = ( } doc.getConnections().forEach((connection) => { - const connectionUserId = connection.request.headers['x-user-id']; - if (connectionUserId === userId) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (connection.context.userId === userId) { connection.close(); } }); diff --git a/src/frontend/servers/y-provider/src/middlewares.ts b/src/frontend/servers/y-provider/src/middlewares.ts index 30470eca..36a18d4a 100644 --- a/src/frontend/servers/y-provider/src/middlewares.ts +++ b/src/frontend/servers/y-provider/src/middlewares.ts @@ -40,17 +40,16 @@ export const wsSecurity = ( ): void => { // Origin check const origin = req.headers['origin']; - if (origin && !allowedOrigins.includes(origin)) { + if (!origin || !allowedOrigins.includes(origin)) { ws.close(4001, 'Origin not allowed'); console.error('CORS policy violation: Invalid Origin', origin); return; } - // Secret API Key check - const apiKey = req.headers['authorization']; - if (apiKey !== COLLABORATION_SERVER_SECRET) { - console.error('Forbidden: Invalid API Key'); - ws.close(); + const cookies = req.headers['cookie']; + if (!cookies) { + ws.close(4001, 'No cookies'); + console.error('CORS policy violation: No cookies'); return; } diff --git a/src/frontend/servers/y-provider/src/servers/hocusPocusServer.ts b/src/frontend/servers/y-provider/src/servers/hocusPocusServer.ts index dc159a8e..70c3eb2d 100644 --- a/src/frontend/servers/y-provider/src/servers/hocusPocusServer.ts +++ b/src/frontend/servers/y-provider/src/servers/hocusPocusServer.ts @@ -1,41 +1,74 @@ import { Server } from '@hocuspocus/server'; +import { fetchDocument } from '@/api/getDoc'; +import { getMe } from '@/api/getMe'; import { logger } from '@/utils'; export const hocusPocusServer = Server.configure({ name: 'docs-collaboration', timeout: 30000, quiet: true, - onConnect({ requestHeaders, connection, documentName, requestParameters }) { + async onConnect({ + requestHeaders, + connection, + documentName, + requestParameters, + context, + }) { const roomParam = requestParameters.get('room'); - const canEdit = requestHeaders['x-can-edit'] === 'True'; - - if (!canEdit) { - connection.readOnly = true; - } - - logger( - 'Connection established:', - documentName, - 'userId:', - requestHeaders['x-user-id'], - 'canEdit:', - canEdit, - 'room:', - requestParameters.get('room'), - ); if (documentName !== roomParam) { console.error( 'Invalid room name - Probable hacking attempt:', documentName, requestParameters.get('room'), - requestHeaders['x-user-id'], ); - return Promise.reject(new Error('Unauthorized')); + return Promise.reject(new Error('Wrong room name: Unauthorized')); } + let can_edit = false; + + try { + const document = await fetchDocument(documentName, requestHeaders); + + if (!document.abilities.retrieve) { + console.error( + 'onConnect: Unauthorized to retrieve this document', + roomParam, + ); + return Promise.reject(new Error('Wrong abilities:Unauthorized')); + } + + can_edit = document.abilities.update; + } catch (error: unknown) { + if (error instanceof Error) { + console.error('onConnect: backend error', error.message); + } + + return Promise.reject(new Error('Backend error: Unauthorized')); + } + + connection.readOnly = !can_edit; + + /* + * Unauthenticated users can be allowed to connect + * so we flag only authenticated users + */ + try { + const user = await getMe(requestHeaders); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + context.userId = user.id; + } catch {} + + logger( + 'Connection established:', + documentName, + 'canEdit:', + can_edit, + 'room:', + requestParameters.get('room'), + ); return Promise.resolve(); }, }); diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 5927e8e6..5328c165 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -7107,6 +7107,15 @@ axe-core@^4.10.0: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.10.2.tgz#85228e3e1d8b8532a27659b332e39b7fa0e022df" integrity sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w== +axios@1.8.2: + version "1.8.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.8.2.tgz#fabe06e241dfe83071d4edfbcaa7b1c3a40f7979" + integrity sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + axobject-query@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-4.1.0.tgz#28768c76d0e3cff21bc62a9e2d0b6ac30042a1ee" @@ -9180,6 +9189,11 @@ flatted@^3.3.3: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358" integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== +follow-redirects@^1.15.6: + version "1.15.9" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" + integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== + fontkit@^2.0.2: version "2.0.4" resolved "https://registry.yarnpkg.com/fontkit/-/fontkit-2.0.4.tgz#4765d664c68b49b5d6feb6bd1051ee49d8ec5ab0"