mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
`resizeAvatar` previously called `node-fetch` on any string input with no validation. When OIDC providers surface a user-controllable `picture` claim, this could be used to make blind SSRF requests to internal services on every social login. Wrap the URL fetch with: - An allowlist on the URL protocol (http/https only). - The shared `createSSRFSafeAgents` utility, which blocks resolution to private, loopback, and link-local IPs at TCP connect time (TOCTOU-safe; works equally for hostname targets that DNS-resolve privately and for IP-literal targets, since Node's `net.Socket` always dispatches through the agent's `lookup` hook). - `redirect: 'error'` so a public-IP redirect target cannot be used to bypass the agent check on a subsequent hop. - A 5-second total request budget (node-fetch v2's `timeout` covers request initiation through full body receipt, bounding slow-loris exposure rather than just the TCP connect). - A 10 MB response cap (`size` option + `Content-Length` pre-check + post-read length assertion) so a hostile payload cannot exhaust memory before `sharp()` rejects it. Fetch the canonicalized `parsed.href` rather than the raw input string to eliminate any future parser-differential between `new URL()` and the underlying fetch implementation. Per-call agent construction is intentional: the avatar path runs once per social login per user, so pooling adds complexity without a measurable benefit. Documented inline. Comprehensive test coverage in `avatar.spec.js`: - Rejects malformed URLs, non-http(s) schemes (file://, data:, javascript:). - Asserts the happy-path canonicalization (`fetch` is called with `parsed.href`) and the SSRF-safe agent factory routing (https→httpsAgent, http→httpAgent). - Rejects non-2xx HTTP status. - Rejects an oversized Content-Length before reading the body, and asserts `.buffer()` is never invoked in that case. - Rejects an oversized body even when the server lies about / omits Content-Length. - Surfaces ESSRF, redirect, and `size` overflow errors thrown by the fetch layer. - Confirms Buffer inputs bypass the fetcher entirely.
189 lines
7 KiB
JavaScript
189 lines
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);
|
|
});
|
|
});
|
|
|
|
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'));
|
|
});
|
|
});
|
|
});
|