{"record":{"id":"ef8aaf80fa6ecbcc","repo":"koala73/worldmonitor","slug":"sentry-pagination-cursor-left-sentry-host-new-url-next","errorCode":null,"errorMessage":"Sentry pagination cursor left ${SENTRY_HOST}: ${new URL(next).origin}","messagePattern":"Sentry pagination cursor left (.+?): (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"scripts/audit-sentry-resolve-pins.mjs","lineNumber":214,"sourceCode":"    allowPositionals: false,\n    strict: true,\n  });\n  return values;\n}\n\nexport function issuesUrl(org, project) {\n  const url = new URL(`${SENTRY_HOST}/api/0/projects/${org}/${project}/issues/`);\n  url.searchParams.set('query', 'is:resolved');\n  url.searchParams.set('limit', String(PAGE_SIZE));\n  url.searchParams.set('statsPeriod', '');\n  return url.toString();\n}\n\n// The cursor URL is echoed straight back from a response header and is then\n// sent the bearer token, so it is pinned to the host we chose to trust.\nfunction sameOriginCursor(next) {\n  if (new URL(next).origin !== new URL(SENTRY_HOST).origin) {\n    throw new Error(`Sentry pagination cursor left ${SENTRY_HOST}: ${new URL(next).origin}`);\n  }\n  return next;\n}\n\nexport async function fetchResolvedIssues(token, org, project, fetchImpl = globalThis.fetch) {\n  const issues = [];\n  let url = issuesUrl(org, project);\n\n  for (let page = 0; page < MAX_PAGES; page += 1) {\n    const response = await fetchImpl(url, {\n      headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },\n      signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),\n    });\n    if (response.status === 403) {\n      throw new Error(\n        'Sentry returned 403 for the issues endpoint. A release-scoped `sntrys_` upload '\n          + 'token cannot read issues, no matter which project it was minted for. Export a '\n          + '`sntryu_` user token carrying `event:read` and `project:read` as SENTRY_AUTH_TOKEN '","sourceCodeStart":196,"sourceCodeEnd":232,"githubUrl":"https://github.com/koala73/worldmonitor/blob/7d06c8633d256c18e38133030bc3613976a96ec9/scripts/audit-sentry-resolve-pins.mjs#L196-L232","documentation":"Sentry echoes the next-page cursor back as a URL in a response header, and the script then sends the bearer token to that URL. To prevent a malicious or compromised header from leaking the token to another host, sameOriginCursor pins the cursor to the origin of SENTRY_HOST and throws if the cursor's origin differs.","triggerScenarios":"Calling sameOriginCursor with a `next` URL whose origin (scheme+host+port) differs from SENTRY_HOST — e.g. the Link header points at http:// while SENTRY_HOST is https://, a different subdomain or mirror host, or a URL that new URL() resolves to another origin.","commonSituations":"SENTRY_HOST is overridden to a self-hosted Sentry whose pagination headers still advertise sentry.io; a reverse proxy or load balancer rewrites the Link header to an internal hostname; an http-vs-https scheme mismatch between the configured host and the echoed cursor.","solutions":["Set SENTRY_HOST so its origin exactly matches the origin in the returned cursor URL (scheme, host, and port), e.g. both https://sentry.io.","Inspect the Link header from the failing response (curl -i) to see which origin Sentry echoes and align SENTRY_HOST with it.","Fix proxy/load-balancer header rewriting so the echoed cursor keeps the public origin.","Never disable the check — it protects the bearer token; change SENTRY_HOST, not the validation."],"exampleFix":"// before\nSENTRY_HOST=http://sentry.internal.example\n// after\nSENTRY_HOST=https://sentry.internal.example  # must match the cursor's origin exactly","handlingStrategy":"validation","validationCode":"const next = extractCursor(response.headers.get('Link'));\nif (next && new URL(next).origin !== new URL(SENTRY_HOST).origin) {\n  throw new Error(`Cursor origin mismatch: ${new URL(next).origin}`);\n}","typeGuard":"const isSameOriginCursor = (next) => { try { return new URL(next).origin === new URL(SENTRY_HOST).origin; } catch { return false; } };","tryCatchPattern":"try {\n  url = sameOriginCursor(next);\n} catch (err) {\n  if (err.message.includes('pagination cursor left')) {\n    console.error(`Refusing cross-origin cursor (token-exfiltration guard): ${err.message}. Align SENTRY_HOST with the echoed origin.`);\n    process.exitCode = 1;\n  } else throw err;\n}","preventionTips":["Configure SENTRY_HOST with the exact scheme+host+port Sentry echoes in its Link header.","Check proxy/load-balancer rewriting of Link headers when self-hosting.","Never follow cursor URLs to other hosts — the bearer token would be sent with them.","Wrap new URL() parsing in try/catch when handling untrusted header values."],"tags":["security","sentry","url","pagination"],"backgroundTag":"invalid-url","analyzedSha":"7d06c8633d256c18e38133030bc3613976a96ec9","analyzedAt":"2026-09-15T16:44:39.439Z","contentChangedAt":"2026-09-15T16:44:39.439Z","schemaVersion":2},"datasetVersion":"2026-09-15T18:17:12.389Z"}