{"record":{"id":"0fc764291d40f88f","repo":"danny-avila/LibreChat","slug":"invalid-avatar-url","errorCode":null,"errorMessage":"Invalid avatar URL","messagePattern":"Invalid avatar URL","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"api/server/services/Files/images/avatar.js","lineNumber":34,"sourceCode":"\n/**\n * Fetches an image URL with SSRF protection: rejects non-http(s) schemes,\n * 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  });","sourceCodeStart":16,"sourceCodeEnd":52,"githubUrl":"https://github.com/danny-avila/LibreChat/blob/5ff282f9006c436e561de1afd39a481bea1ef0d8/api/server/services/Files/images/avatar.js#L16-L52","documentation":"Thrown by fetchAvatarBuffer when the input string cannot be parsed by the URL constructor. This is the first validation gate in the avatar fetch pipeline: if new URL(input) throws, the input is not a valid absolute URL (it may be a relative path, a bare string, undefined coerced to string, or a malformed URL with invalid syntax). The error fires before any protocol or SSRF checks.","triggerScenarios":"Calling fetchAvatarBuffer(input) or resizeAvatar({ input }) where input is a string that the URL constructor rejects — e.g., '/path/to/avatar.png' (relative), '' (empty), 'not-a-url', 'ftp://...' (would pass URL parse but fail protocol check), or a value with invalid characters.","commonSituations":"A social login provider (Google, GitHub, Facebook) returns a relative avatar URL or an empty/null picture field. Or the user's profile picture field in the database is null/undefined and gets coerced to the string 'undefined'. Or a frontend bug sends a relative path instead of an absolute URL. Or the input is a Buffer or File but typeof input !== 'string' is true and the code path incorrectly reaches the string branch.","solutions":["Validate the avatar URL is an absolute http(s) URL before calling resizeAvatar — use a try/catch around new URL() or a regex.","Handle null/undefined/empty picture fields from OAuth providers by falling back to a default avatar.","If the provider returns a relative URL, prepend the provider's base domain (e.g., 'https://avatars.githubusercontent.com' for GitHub).","Add a type check: if typeof input !== 'string', route to the Buffer or File branch instead of attempting URL parsing."],"exampleFix":"// before\nconst buffer = await resizeAvatar({ userId, input: profile.picture });\n\n// after — validate and fallback\nconst avatarUrl = profile.picture;\nif (typeof avatarUrl === 'string' && /^https?:\\/\\//.test(avatarUrl)) {\n  const buffer = await resizeAvatar({ userId, input: avatarUrl });\n} else {\n  const buffer = await resizeAvatar({ userId, input: DEFAULT_AVATAR_URL });\n}","handlingStrategy":"validation","validationCode":"function isValidAvatarUrl(input: unknown): input is string {\n  if (typeof input !== 'string') return false;\n  try {\n    const parsed = new URL(input);\n    return parsed.protocol === 'http:' || parsed.protocol === 'https:';\n  } catch {\n    return false;\n  }\n}\n\nif (!isValidAvatarUrl(picture)) {\n  picture = DEFAULT_AVATAR_URL;\n}","typeGuard":"function isValidAvatarUrl(input: unknown): input is string {\n  if (typeof input !== 'string') return false;\n  try {\n    const parsed = new URL(input);\n    return parsed.protocol === 'http:' || parsed.protocol === 'https:';\n  } catch {\n    return false;\n  }\n}","tryCatchPattern":"try {\n  const buffer = await resizeAvatar({ userId, input: picture });\n} catch (error) {\n  if (error.message === 'Invalid avatar URL') {\n    // fallback to default avatar\n    const buffer = await resizeAvatar({ userId, input: DEFAULT_AVATAR_URL });\n  }\n  throw error;\n}","preventionTips":["Validate avatar URLs from OAuth providers before persisting — they may be null, empty, or relative.","Fall back to a default avatar when the provider's picture URL is missing or invalid.","Prepend the provider's base domain if the OAuth provider returns a relative avatar path.","Type-check input before calling resizeAvatar to route Buffers/Files correctly."],"tags":["avatar","url-validation","input-validation","oauth","ssrf-protection"],"backgroundTag":null,"analyzedSha":"5ff282f9006c436e561de1afd39a481bea1ef0d8","analyzedAt":"2026-08-12T21:38:08.145Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}