{"record":{"id":"fb3cdccff720eea5","repo":"danny-avila/LibreChat","slug":"refusing-to-fetch-avatar-over-parsed-protocol","errorCode":null,"errorMessage":"Refusing to fetch avatar over ${parsed.protocol}","messagePattern":"Refusing to fetch avatar over (.+?)","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"warning","filePath":"api/server/services/Files/images/avatar.js","lineNumber":37,"sourceCode":" * blocks resolution to private/loopback/link-local IPs at TCP connect time,\n * refuses to follow redirects to prevent post-validation rebinding, and caps\n * the response body so a hostile payload cannot exhaust memory before\n * `sharp()` rejects it.\n *\n * Per-call agent construction is intentional: avatar fetches are infrequent\n * (once per social login per user) and pooling adds complexity without a\n * measurable benefit on this path. If this ever becomes a hot path, hoist\n * the agents to module scope.\n */\nasync function fetchAvatarBuffer(input, fetchOptions = {}) {\n  let parsed;\n  try {\n    parsed = new URL(input);\n  } catch {\n    throw new Error('Invalid avatar URL');\n  }\n  if (!ALLOWED_AVATAR_PROTOCOLS.has(parsed.protocol)) {\n    throw new Error(`Refusing to fetch avatar over ${parsed.protocol}`);\n  }\n\n  const { httpAgent, httpsAgent } = createSSRFSafeAgents();\n  /**\n   * `node-fetch` v2's `timeout` is the total request budget (request initiation\n   * through full body receipt), not a TCP-connect-only timeout. That is the\n   * stronger of the two for this path — bounds total slow-loris exposure.\n   */\n  const response = await fetch(parsed.href, {\n    headers: fetchOptions.headers,\n    agent: (urlObj) => (urlObj.protocol === 'https:' ? httpsAgent : httpAgent),\n    redirect: 'error',\n    timeout: 5000,\n    size: MAX_AVATAR_BYTES,\n  });\n\n  if (!response.ok) {\n    throw new Error(`Failed to fetch image from URL. Status: ${response.status}`);","sourceCodeStart":19,"sourceCodeEnd":55,"githubUrl":"https://github.com/danny-avila/LibreChat/blob/5ff282f9006c436e561de1afd39a481bea1ef0d8/api/server/services/Files/images/avatar.js#L19-L55","documentation":"Thrown by fetchAvatarBuffer when the parsed avatar URL uses a protocol outside ALLOWED_AVATAR_PROTOCOLS (only `http:` and `https:`). This is an SSRF guard: it prevents the server from attempting fetches over schemes like `file:`, `ftp:`, `data:`, or `gopher:` that node-fetch might otherwise route or that an attacker could use to read local resources. The check happens before any network I/O, immediately after `new URL(input)` succeeds.","triggerScenarios":"A user's `picture` field (social login profile photo or manually set avatar URL) is a string that parses as a URL but whose scheme is not `http:` or `https:`. Examples: `file:///etc/passwd`, `ftp://host/img.png`, `data:image/png;base64,...`, or a malformed value like `gopher://x`. Passing such a value to uploadAvatar's URL branch hits this branch.","commonSituations":"Social providers returning non-HTTP avatar URLs; misconfigured test fixtures using `file://` paths; a frontend that accepts a user-pasted avatar URL without client-side scheme validation; data-URI uploads routed through the URL path instead of the base64 path.","solutions":["Ensure the avatar URL passed in starts with `http://` or `https://` — validate the scheme on the client before submit.","If the input is actually a base64 data URI, route it through saveBase64Image instead of the URL fetch path.","If the input is a local file path, pass it as a File object (the File branch) rather than a `file://` URL.","Sanitize stored `picture` values from OAuth providers before persisting them to the user record."],"exampleFix":"// before\nawait uploadAvatar({ userId, input: 'file:///tmp/avatar.png' });\n\n// after (local file -> File branch)\nawait uploadAvatar({ userId, input: new File('/tmp/avatar.png') });\n// or enforce https in the caller\nconst url = new URL(raw);\nif (url.protocol !== 'https:') throw new Error('Avatar URL must be https');","handlingStrategy":"validation","validationCode":"function isSafeAvatarUrl(input) {\n  if (typeof input !== 'string') return false;\n  let u;\n  try { u = new URL(input); } catch { return false; }\n  return u.protocol === 'http:' || u.protocol === 'https:';\n}\n// before uploadAvatar:\nif (typeof input === 'string' && !isSafeAvatarUrl(input)) {\n  throw new Error('Avatar URL must be http(s)');\n}","typeGuard":"function isHttpUrl(s) {\n  if (typeof s !== 'string') return false;\n  try { return ['http:', 'https:'].includes(new URL(s).protocol); }\n  catch { return false; }\n}","tryCatchPattern":"try {\n  await uploadAvatar({ userId, input });\n} catch (e) {\n  if (/Refusing to fetch avatar over/.test(e.message)) {\n    return res.status(400).json({ error: 'Avatar URL must use http or https' });\n  }\n  throw e;\n}","preventionTips":["Validate the avatar URL scheme in the OAuth/social callback before persisting `picture`.","On the frontend, reject non-http(s) avatar URLs at the input field.","Treat base64 data URIs as base64 input, not as URL input."],"tags":["ssrf","avatar","url-validation","security"],"backgroundTag":null,"analyzedSha":"5ff282f9006c436e561de1afd39a481bea1ef0d8","analyzedAt":"2026-08-12T21:38:08.145Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}