{"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/aac998c7ed7248ea853b720ceeb1fdbeb322fc5d/providers/rippling.mjs#L40-L76","documentation":"The rippling provider pins requests to a single trusted API host (API_HOST). assertRipplingApiUrl compares the parsed hostname against API_HOST and throws this error on mismatch, preventing SSRF and accidental calls to lookalike or mirror domains.","triggerScenarios":"A rippling API URL whose parsed hostname !== API_HOST — e.g. https://rippling.com/... instead of the API subdomain, a typo'd host, or an attacker/config-supplied foreign hostname.","commonSituations":"Using the marketing domain instead of the API host, DNS/search-replaced URLs, proxies embedded in the URL, or config values copied from another provider.","solutions":["Use the exact API host expected (compare your URL's hostname to the API_HOST constant in providers/rippling.mjs).","Fix typos or subdomain mistakes in the entry config.","Route through proxies at the network layer, not by rewriting the URL hostname."],"exampleFix":"// before\nurl = 'https://rippling.com/api/ats/jobs';\n// after\nurl = `https://${API_HOST}/api/ats/jobs`; // API_HOST e.g. api.rippling.com","handlingStrategy":"validation","validationCode":"const expectedHost = 'api.rippling.com'; // mirror of API_HOST\nconst hostOk = (u) => { try { return new URL(u).hostname === expectedHost; } catch { return false; } };\nif (!hostOk(entry.url)) throw new Error(`skip: ${entry.url} is not the Rippling API host`);","typeGuard":"const isRipplingApi = (u) => { try { return new URL(u).hostname === 'api.rippling.com'; } catch { return false; } };","tryCatchPattern":"try { await provider.fetch(entry, ctx); } catch (e) { if (e.message.includes('untrusted hostname')) { console.error(`Entry ${entry.name} URL host mismatch`); return null; } throw e; }","preventionTips":["Build API URLs from the API_HOST constant, never from raw config strings.","Allowlist hostnames before calling any provider.","Don't route proxies by rewriting the URL hostname."],"tags":["url-validation","ssrf","security","hostname"],"backgroundTag":"invalid-url","analyzedSha":"aac998c7ed7248ea853b720ceeb1fdbeb322fc5d","analyzedAt":"2026-09-16T06:35:29.214Z","contentChangedAt":"2026-09-16T06:35:29.214Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}