{"record":{"id":"668c6254e64d14c7","repo":"santifer/career-ops","slug":"rippling-untrusted-hostname-parsed-hostname","errorCode":null,"errorMessage":"rippling: untrusted hostname \"${parsed.hostname}\" — must be ${API_HOST}","messagePattern":"rippling: untrusted hostname \"(.+?)\" — must be (.+?)","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"providers/rippling.mjs","lineNumber":58,"sourceCode":"  return segment;\n}\n\n/** Build the board API URL for a validated slug. */\nfunction apiUrlForSlug(slug) {\n  return `${API_BASE}/${encodeURIComponent(slug)}/jobs`;\n}\n\n/** @param {string} url */\nfunction assertRipplingApiUrl(url) {\n  let parsed;\n  try {\n    parsed = new URL(url);\n  } catch {\n    throw new Error(`rippling: invalid URL: ${url}`);\n  }\n  if (parsed.protocol !== 'https:') throw new Error(`rippling: URL must use HTTPS: ${url}`);\n  if (parsed.hostname !== API_HOST) {\n    throw new Error(`rippling: untrusted hostname \"${parsed.hostname}\" — must be ${API_HOST}`);\n  }\n  return url;\n}\n\n/** @type {Provider} */\nexport default {\n  id: 'rippling',\n\n  detect(entry) {\n    const slug = resolveSlug(entry);\n    return slug ? { url: apiUrlForSlug(slug) } : null;\n  },\n\n  async fetch(entry, ctx) {\n    const slug = resolveSlug(entry);\n    if (!slug) throw new Error(`rippling: cannot derive API URL for ${entry.name}`);\n    const apiUrl = apiUrlForSlug(slug);\n    assertRipplingApiUrl(apiUrl);","sourceCodeStart":40,"sourceCodeEnd":76,"githubUrl":"https://github.com/santifer/career-ops/blob/9b17a8ac97b398a496b38e423ae24e433b43254f/providers/rippling.mjs#L40-L76","documentation":"assertRipplingApiUrl throws when parsed.hostname is not exactly API_HOST ('api.rippling.com'). This is a strict single-host SSRF guard: rippling's API lives on one fixed host, so any other hostname — including a careers host like ats.rippling.com or a branded domain — is rejected at the API layer.","triggerScenarios":"The API URL was constructed pointing at ats.rippling.com (the careers host) instead of api.rippling.com; a redirect or config change swapped the host; a test or external caller passed a URL for a different host.","commonSituations":"apiUrlForSlug was bypassed and the careers URL was used directly; the API_HOST constant was changed; a slug injection attempted path traversal to a different host.","solutions":["Always build the API URL via apiUrlForSlug(slug), which uses API_BASE on api.rippling.com.","Confirm API_HOST is 'api.rippling.com' and was not overridden.","Ensure the slug passed SLUG_RE before URL construction so no host-redirecting characters slip through.","Do not pass the careers_url (ats.rippling.com) into assertRipplingApiUrl — pass the derived API URL."],"exampleFix":"// before — careers URL misused as API URL\nassertRipplingApiUrl('https://ats.rippling.com/acme/jobs');\n// after — derived API URL on the correct host\nassertRipplingApiUrl(apiUrlForSlug('acme'));","handlingStrategy":"validation","validationCode":"const API_HOST = 'api.rippling.com';\nfunction isRipplingApiUrl(url) {\n  try { return new URL(url).hostname === API_HOST; } catch { return false; }\n}\nconst apiUrl = apiUrlForSlug(slug);\nif (!isRipplingApiUrl(apiUrl)) {\n  throw new Error('rippling: derived API URL is not on api.rippling.com');\n}","typeGuard":"null","tryCatchPattern":"try {\n  await provider.fetch(entry, ctx);\n} catch (e) {\n  if (/untrusted hostname/.test(e.message)) {\n    console.error('[bug] rippling API URL on wrong host — use apiUrlForSlug, not the careers URL');\n  } else throw e;\n}","preventionTips":["Never pass the careers_url (ats.rippling.com) into assertRipplingApiUrl — always the derived API URL.","Build API URLs exclusively via apiUrlForSlug(slug).","Validate the slug with SLUG_RE before construction to block path-traversal attempts."],"tags":["ssrf","url-validation","hostname-allowlist","provider","rippling","security"],"backgroundTag":null,"analyzedSha":"9b17a8ac97b398a496b38e423ae24e433b43254f","analyzedAt":"2026-08-13T00:48:39.135Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}