mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-03 04:42:11 +00:00
209 lines
7.7 KiB
JavaScript
209 lines
7.7 KiB
JavaScript
/**
|
|
* Tests for the SSRF-safe avatar fetcher in `avatar.js`.
|
|
*
|
|
* The function is the sole line of defense against SSRF when a social
|
|
* login surfaces a user-controllable `picture` URL. We assert each
|
|
* rejection branch (protocol, status, redirect, size, agent) and the
|
|
* happy path so that a future refactor of the fetch / agent / URL
|
|
* handling cannot silently break the protection.
|
|
*/
|
|
jest.mock('node-fetch');
|
|
jest.mock('@librechat/api', () => ({
|
|
createSSRFSafeAgents: jest.fn(() => ({
|
|
httpAgent: { __kind: 'http' },
|
|
httpsAgent: { __kind: 'https' },
|
|
})),
|
|
}));
|
|
jest.mock('@librechat/data-schemas', () => ({
|
|
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
|
|
}));
|
|
jest.mock('librechat-data-provider', () => ({
|
|
EImageOutputType: { PNG: 'png' },
|
|
}));
|
|
jest.mock('./resize', () => ({
|
|
resizeAndConvert: jest.fn(async ({ inputBuffer }) => ({ buffer: inputBuffer })),
|
|
}));
|
|
jest.mock('sharp', () => {
|
|
const sharpFn = jest.fn();
|
|
return sharpFn;
|
|
});
|
|
|
|
const fetch = require('node-fetch');
|
|
const { createSSRFSafeAgents } = require('@librechat/api');
|
|
const sharp = require('sharp');
|
|
const { resizeAvatar } = require('./avatar');
|
|
|
|
function makeResponse({ ok = true, status = 200, body = Buffer.from(''), contentLength } = {}) {
|
|
return {
|
|
ok,
|
|
status,
|
|
headers: {
|
|
get: (name) => {
|
|
if (name.toLowerCase() === 'content-length') {
|
|
return contentLength != null ? String(contentLength) : null;
|
|
}
|
|
return null;
|
|
},
|
|
},
|
|
buffer: jest.fn(async () => body),
|
|
};
|
|
}
|
|
|
|
function makeSharpStub(format = 'png', width = 100, height = 100) {
|
|
const chain = {
|
|
metadata: jest.fn(async () => ({ format, width, height })),
|
|
extract: jest.fn(() => chain),
|
|
resize: jest.fn(() => chain),
|
|
gif: jest.fn(() => chain),
|
|
toBuffer: jest.fn(async () => Buffer.from('squared')),
|
|
};
|
|
return chain;
|
|
}
|
|
|
|
const callResize = (input) => resizeAvatar({ userId: 'u1', input });
|
|
|
|
describe('resizeAvatar — fetchAvatarBuffer', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
sharp.mockImplementation(() => makeSharpStub());
|
|
});
|
|
|
|
describe('rejects unsafe inputs before any network call', () => {
|
|
it('rejects a malformed URL string', async () => {
|
|
await expect(callResize('not-a-url')).rejects.toThrow('Invalid avatar URL');
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects file:// URLs', async () => {
|
|
await expect(callResize('file:///etc/passwd')).rejects.toThrow(/Refusing to fetch.*file:/);
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects data: URLs', async () => {
|
|
await expect(callResize('data:image/png;base64,AAAA')).rejects.toThrow(
|
|
/Refusing to fetch.*data:/,
|
|
);
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects javascript: URLs', async () => {
|
|
await expect(callResize('javascript:void(0)')).rejects.toThrow(
|
|
/Refusing to fetch.*javascript:/,
|
|
);
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('happy path', () => {
|
|
it('returns a processed buffer for a valid https URL', async () => {
|
|
fetch.mockResolvedValueOnce(makeResponse({ body: Buffer.from('rawimg') }));
|
|
const result = await callResize('https://cdn.example.com/avatar.png');
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
// `parsed.href` canonicalizes the input — assert we did not pass the raw string.
|
|
expect(fetch.mock.calls[0][0]).toBe('https://cdn.example.com/avatar.png');
|
|
const opts = fetch.mock.calls[0][1];
|
|
expect(opts.redirect).toBe('error');
|
|
expect(opts.timeout).toBe(5000);
|
|
expect(opts.size).toBe(10 * 1024 * 1024);
|
|
expect(typeof opts.agent).toBe('function');
|
|
expect(result).toEqual(Buffer.from('squared'));
|
|
});
|
|
|
|
it('passes an SSRF-safe agent factory routing https→httpsAgent and http→httpAgent', async () => {
|
|
fetch.mockResolvedValueOnce(makeResponse({ body: Buffer.from('rawimg') }));
|
|
await callResize('https://cdn.example.com/avatar.png');
|
|
const agentFn = fetch.mock.calls[0][1].agent;
|
|
expect(agentFn(new URL('https://anything'))).toEqual({ __kind: 'https' });
|
|
expect(agentFn(new URL('http://anything'))).toEqual({ __kind: 'http' });
|
|
expect(createSSRFSafeAgents).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('passes configured fetch headers while preserving shared fetch controls', async () => {
|
|
fetch.mockResolvedValueOnce(makeResponse({ body: Buffer.from('rawimg') }));
|
|
await resizeAvatar({
|
|
userId: 'u1',
|
|
input: 'https://cdn.example.com/avatar.png',
|
|
fetchOptions: {
|
|
headers: {
|
|
Authorization: 'Bearer avatar-token',
|
|
},
|
|
},
|
|
});
|
|
|
|
const opts = fetch.mock.calls[0][1];
|
|
expect(opts.headers).toEqual({ Authorization: 'Bearer avatar-token' });
|
|
expect(opts.redirect).toBe('error');
|
|
expect(opts.timeout).toBe(5000);
|
|
expect(opts.size).toBe(10 * 1024 * 1024);
|
|
expect(typeof opts.agent).toBe('function');
|
|
});
|
|
});
|
|
|
|
describe('rejects unsafe responses', () => {
|
|
it('rejects non-2xx HTTP status', async () => {
|
|
fetch.mockResolvedValueOnce(makeResponse({ ok: false, status: 500 }));
|
|
await expect(callResize('https://cdn.example.com/avatar.png')).rejects.toThrow(
|
|
/Status:\s*500/,
|
|
);
|
|
});
|
|
|
|
it('rejects an oversized Content-Length header before reading the body', async () => {
|
|
const oversize = 11 * 1024 * 1024;
|
|
const resp = makeResponse({ contentLength: oversize });
|
|
fetch.mockResolvedValueOnce(resp);
|
|
await expect(callResize('https://cdn.example.com/big.png')).rejects.toThrow(
|
|
/Avatar response too large.*11534336/,
|
|
);
|
|
// We must not even read the body once the header has already disqualified it.
|
|
expect(resp.buffer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects a body whose actual size exceeds the cap (lying / missing Content-Length)', async () => {
|
|
const oversize = Buffer.alloc(11 * 1024 * 1024);
|
|
// No content-length header — server lies or omits.
|
|
fetch.mockResolvedValueOnce(makeResponse({ body: oversize }));
|
|
await expect(callResize('https://cdn.example.com/lies.png')).rejects.toThrow(
|
|
/Avatar response too large.*11534336/,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('propagates fetch-layer errors', () => {
|
|
it('surfaces SSRF rejection thrown by the agent (ESSRF)', async () => {
|
|
const ssrfError = Object.assign(new Error('SSRF protection: 127.0.0.1 blocked'), {
|
|
code: 'ESSRF',
|
|
});
|
|
fetch.mockRejectedValueOnce(ssrfError);
|
|
await expect(callResize('http://internal.attacker.example/img.png')).rejects.toThrow(
|
|
/SSRF protection/,
|
|
);
|
|
});
|
|
|
|
it('surfaces redirect rejection from `redirect: error`', async () => {
|
|
const redirectError = Object.assign(new Error('redirect mode is set to error'), {
|
|
type: 'no-redirect',
|
|
});
|
|
fetch.mockRejectedValueOnce(redirectError);
|
|
await expect(callResize('https://cdn.example.com/redirected.png')).rejects.toThrow(
|
|
/redirect mode/,
|
|
);
|
|
});
|
|
|
|
it('surfaces a `size` overflow thrown by node-fetch', async () => {
|
|
const sizeError = Object.assign(new Error('content size at 11534336 over limit: 10485760'), {
|
|
type: 'max-size',
|
|
});
|
|
fetch.mockRejectedValueOnce(sizeError);
|
|
await expect(callResize('https://cdn.example.com/large.png')).rejects.toThrow(/over limit/);
|
|
});
|
|
});
|
|
|
|
describe('non-string inputs bypass the fetcher', () => {
|
|
it('accepts a Buffer input directly without calling fetch', async () => {
|
|
const buf = Buffer.from('inline');
|
|
const result = await callResize(buf);
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
expect(result).toEqual(Buffer.from('squared'));
|
|
});
|
|
});
|
|
});
|