{"record":{"id":"4aaeb95f285c4a21","repo":"jackwener/OpenCLI","slug":"label-returned-an-untrusted-pixiv-image-url","errorCode":null,"errorMessage":"${label} returned an untrusted Pixiv image URL","messagePattern":"(.+?) returned an untrusted Pixiv image URL","errorType":"exception","errorClass":"CommandExecutionError","httpStatus":null,"severity":"error","filePath":"clis/pixiv/bookmark-download.js","lineNumber":45,"sourceCode":"  if (value !== true) {\n    throw new ArgumentError('Refusing to write local Pixiv downloads: pass --execute');\n  }\n}\n\nfunction parsePixivImageUrl(value, label) {\n  if (typeof value !== 'string' || !value) {\n    throw new CommandExecutionError(`${label} returned a missing image URL`);\n  }\n  let url;\n  try {\n    url = new URL(value);\n  } catch {\n    throw new CommandExecutionError(`${label} returned a malformed image URL`);\n  }\n  const extension = path.extname(url.pathname).toLowerCase();\n  const contentType = IMAGE_CONTENT_TYPES.get(extension);\n  if (url.protocol !== 'https:' || url.hostname !== 'i.pximg.net' || url.username || url.password || url.port || !contentType) {\n    throw new CommandExecutionError(`${label} returned an untrusted Pixiv image URL`);\n  }\n  return { url: url.href, extension, contentType };\n}\n\nasync function prepareIllustPlan(page, row, outputRoot) {\n  const pages = await pixivFetch(page, `/ajax/illust/${row.illust_id}/pages`, {\n    notFoundMsg: `Illustration not found: ${row.illust_id}`,\n  });\n  if (!Array.isArray(pages)) {\n    throw new CommandExecutionError('Pixiv pages API returned malformed payload');\n  }\n  if (pages.length === 0) {\n    throw new EmptyResultError('pixiv bookmark-download', `No images found for illustration ${row.illust_id}.`);\n  }\n  const files = pages.map((entry, index) => {\n    if (!entry || Array.isArray(entry) || typeof entry !== 'object' || !entry.urls || Array.isArray(entry.urls) || typeof entry.urls !== 'object') {\n      throw new CommandExecutionError(`Pixiv illustration ${row.illust_id} returned malformed page ${index + 1}`);\n    }","sourceCodeStart":27,"sourceCodeEnd":63,"githubUrl":"https://github.com/jackwener/OpenCLI/blob/49907e53dc3ade5c223ff0c4c2c2785687cec4e6/clis/pixiv/bookmark-download.js#L27-L63","documentation":"This CommandExecutionError is thrown by parsePixivImageUrl in clis/pixiv/bookmark-download.js:44-46 as a security check: the URL parses but is not a trusted Pixiv CDN image. It must satisfy ALL of: https: protocol, hostname i.pximg.net, no embedded username/password, no explicit port, and a file extension mapped in IMAGE_CONTENT_TYPES (.jpg/.jpeg/.png/.gif/.webp). Any violation rejects the URL to prevent SSRF or downloading unexpected content.","triggerScenarios":"The pages API returns an image URL that is http:, points at a different host (e.g. embed.pixiv.net or a mirrored host), embeds credentials, specifies a port, or has an unmapped extension (e.g. .zip, no extension), failing the guard at clis/pixiv/bookmark-download.js:44.","commonSituations":"Pixiv serving ugoira/animation entries whose sources are not plain images; novels or other asset types leaking into the illust download path; a changed CDN hostname after a Pixiv update; URLs from a third-party mirror or proxy injected into the response; extension-less URLs for certain content types.","solutions":["Log the offending URL and check which condition failed (scheme, host, credentials, port, or extension)","Confirm the content type is actually an image — ugoira/zip or non-image assets are intentionally rejected by this command","Re-authenticate and retry: restricted sessions can make Pixiv return alternate hosts","If Pixiv legitimately changed its CDN host or added extensions, update the hostname allowlist or IMAGE_CONTENT_TYPES map accordingly"],"exampleFix":null,"handlingStrategy":"validation","validationCode":"function isTrustedPixivImageUrl(value) {\n  if (typeof value !== 'string' || !value) return false;\n  let url;\n  try { url = new URL(value); } catch { return false; }\n  const okExt = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(\n    require('node:path').extname(url.pathname).toLowerCase());\n  return url.protocol === 'https:' &&\n    url.hostname === 'i.pximg.net' &&\n    !url.username && !url.password && !url.port && okExt;\n}\nif (!isTrustedPixivImageUrl(raw)) console.warn('Untrusted image URL:', raw);","typeGuard":"const TRUSTED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);\nfunction isPximgImageUrl(value) {\n  let url;\n  try { url = new URL(value); } catch { return false; }\n  return url.protocol === 'https:' &&\n    url.hostname === 'i.pximg.net' &&\n    !url.username && !url.password && !url.port &&\n    TRUSTED_EXTENSIONS.has(path.extname(url.pathname).toLowerCase());\n}","tryCatchPattern":"try {\n  const parsed = parsePixivImageUrl(value, label);\n} catch (e) {\n  if (e instanceof CommandExecutionError && e.message.includes('untrusted Pixiv image URL')) {\n    console.warn(`${label}: URL failed the pximg allowlist — skipping (possible ugoira/non-image asset)`);\n  } else {\n    throw e;\n  }\n}","preventionTips":["Only download plain image works; route ugoira/zip assets through a dedicated handler","Never weaken the hostname/protocol allowlist — keep downloads restricted to https + i.pximg.net","Log the rejected URL to diagnose whether the host, scheme, port, or extension failed","After Pixiv CDN changes, update the allowlist deliberately rather than disabling the check","Re-authenticate if restricted sessions cause alternate hosts to appear in payloads"],"tags":["pixiv","security","ssrf","url-validation","allowlist"],"backgroundTag":"untrusted-url-rejected","analyzedSha":"49907e53dc3ade5c223ff0c4c2c2785687cec4e6","analyzedAt":"2026-08-29T08:14:47.543Z","schemaVersion":2},"datasetVersion":"2026-08-29T12:17:43.993Z"}