{"record":{"id":"91ab00b1add5192a","repo":"Budibase/budibase","slug":"invalid-cursor","errorCode":null,"errorMessage":"Invalid cursor","messagePattern":"Invalid cursor","errorType":"http","errorClass":"HTTPError","httpStatus":400,"severity":"warning","filePath":"packages/server/src/escalation/notifications/ms-teams.ts","lineNumber":108,"sourceCode":"const graphGet = async <T>(url: string, token: string): Promise<T> => {\n  const resp = await fetch(url, {\n    headers: { Authorization: `Bearer ${token}` },\n  })\n  if (!resp.ok) {\n    throw new Error(`Teams Graph API ${resp.status}: ${await resp.text()}`)\n  }\n  return (await resp.json()) as T\n}\n\n// Opaque base64url Graph nextLink. Validate origin + exact pathname so the\n// Graph token can only ever be sent to the teams collection.\nconst decodeTeamsCursor = (cursor: string): string => {\n  const decoded = Buffer.from(cursor, \"base64url\").toString()\n  let url: URL\n  try {\n    url = new URL(decoded)\n  } catch {\n    throw new HTTPError(\"Invalid cursor\", 400)\n  }\n  if (\n    url.origin !== \"https://graph.microsoft.com\" ||\n    url.pathname !== \"/v1.0/teams\"\n  ) {\n    throw new HTTPError(\"Invalid cursor\", 400)\n  }\n  return url.toString()\n}\n\n// Lists channels for one page of teams the app can see, using a Graph token (a\n// separate scope from the bot credentials). Requires Team.ReadBasic.All and\n// Channel.ReadBasic.All application permissions consented in Azure.\nexport const listTeamsChannels = async (\n  graphToken: string,\n  cursor?: string\n): Promise<{\n  channels: { id: string; name: string; teamId: string; teamName: string }[]","sourceCodeStart":90,"sourceCodeEnd":126,"githubUrl":"https://github.com/Budibase/budibase/blob/a81a902e9a8fe55b467d106765f6638f12e35c49/packages/server/src/escalation/notifications/ms-teams.ts#L90-L126","documentation":"decodeTeamsCursor decodes an opaque base64url pagination cursor back into the Graph nextLink URL. If the string is not valid base64url or does not decode into a parseable URL, a 400 HTTPError(\"Invalid cursor\") is thrown. This protects the caller from being handed arbitrary URLs.","triggerScenarios":"Calling listTeamsChannels (or the API route using it) with a cursor query param that is not valid base64url — e.g. an empty string, truncated value, URL-encoded characters mangled in transit, or a cursor typed by hand.","commonSituations":"Client double-decodes or re-encodes the cursor; cursor value trimmed/modified in a URL query string; using a cursor from a different endpoint or an older app version; manually constructing pagination params.","solutions":["Send back the cursor exactly as returned in the previous listTeamsChannels response, without decoding or re-encoding it.","If the cursor was lost or corrupted, restart pagination from the first page by omitting the cursor parameter.","Check the client doesn't strip/transform base64url characters (e.g. leading/trailing whitespace or '+'/'/' substitutions) when storing the cursor."],"exampleFix":"// before\nconst cursor = atob(rawCursor) // client tampered with the value\n// after\nconst cursor = rawCursor // pass opaque base64url value through unchanged\nfetch(\"/api/teams/channels?cursor=\" + encodeURIComponent(nextCursor))","handlingStrategy":"validation","validationCode":"const isValidCursor = (c: unknown): c is string =>\n  typeof c === \"string\" && c.length > 0 && /^[A-Za-z0-9_-]+$/.test(c)","typeGuard":"const isTeamsCursor = (c: unknown): c is string =>\n  typeof c === \"string\" &&\n  c.length > 0 &&\n  /^[A-Za-z0-9_-]+$/.test(c) &&\n  (() => { try { return new URL(Buffer.from(c, \"base64url\").toString()).origin === \"https://graph.microsoft.com\" } catch { return false } })()","tryCatchPattern":"try {\n  return await listTeamsChannels(token, cursor)\n} catch (err) {\n  if (err instanceof HTTPError && err.message === \"Invalid cursor\") {\n    return listTeamsChannels(token) // restart from first page\n  }\n  throw err\n}","preventionTips":["Treat cursors as opaque strings: never decode, trim, or re-encode them client-side","encodeURIComponent the cursor when placing it in a query string","Fall back to first-page listing when a cursor is rejected"],"tags":["pagination","input-validation","http-400","teams"],"backgroundTag":"invalid-pagination-cursor","analyzedSha":"a81a902e9a8fe55b467d106765f6638f12e35c49","analyzedAt":"2026-08-29T01:03:10.972Z","schemaVersion":2},"datasetVersion":"2026-08-29T02:17:18.158Z"}