Budibase/budibase · warning · HTTPError

Invalid cursor

Error message

Invalid cursor

What it means

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.

Source

Thrown at packages/server/src/escalation/notifications/ms-teams.ts:108

const graphGet = async <T>(url: string, token: string): Promise<T> => {
  const resp = await fetch(url, {
    headers: { Authorization: `Bearer ${token}` },
  })
  if (!resp.ok) {
    throw new Error(`Teams Graph API ${resp.status}: ${await resp.text()}`)
  }
  return (await resp.json()) as T
}

// Opaque base64url Graph nextLink. Validate origin + exact pathname so the
// Graph token can only ever be sent to the teams collection.
const decodeTeamsCursor = (cursor: string): string => {
  const decoded = Buffer.from(cursor, "base64url").toString()
  let url: URL
  try {
    url = new URL(decoded)
  } catch {
    throw new HTTPError("Invalid cursor", 400)
  }
  if (
    url.origin !== "https://graph.microsoft.com" ||
    url.pathname !== "/v1.0/teams"
  ) {
    throw new HTTPError("Invalid cursor", 400)
  }
  return url.toString()
}

// Lists channels for one page of teams the app can see, using a Graph token (a
// separate scope from the bot credentials). Requires Team.ReadBasic.All and
// Channel.ReadBasic.All application permissions consented in Azure.
export const listTeamsChannels = async (
  graphToken: string,
  cursor?: string
): Promise<{
  channels: { id: string; name: string; teamId: string; teamName: string }[]

View on GitHub (pinned to a81a902e9a)

Solutions

  1. Send back the cursor exactly as returned in the previous listTeamsChannels response, without decoding or re-encoding it.
  2. If the cursor was lost or corrupted, restart pagination from the first page by omitting the cursor parameter.
  3. Check the client doesn't strip/transform base64url characters (e.g. leading/trailing whitespace or '+'/'/' substitutions) when storing the cursor.

Example fix

// before
const cursor = atob(rawCursor) // client tampered with the value
// after
const cursor = rawCursor // pass opaque base64url value through unchanged
fetch("/api/teams/channels?cursor=" + encodeURIComponent(nextCursor))
Defensive patterns

Strategy: validation

Validate before calling

const isValidCursor = (c: unknown): c is string =>
  typeof c === "string" && c.length > 0 && /^[A-Za-z0-9_-]+$/.test(c)

Type guard

const isTeamsCursor = (c: unknown): c is string =>
  typeof c === "string" &&
  c.length > 0 &&
  /^[A-Za-z0-9_-]+$/.test(c) &&
  (() => { try { return new URL(Buffer.from(c, "base64url").toString()).origin === "https://graph.microsoft.com" } catch { return false } })()

Try / catch

try {
  return await listTeamsChannels(token, cursor)
} catch (err) {
  if (err instanceof HTTPError && err.message === "Invalid cursor") {
    return listTeamsChannels(token) // restart from first page
  }
  throw err
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of Budibase/budibase@a81a902e9a (2026-08-29). Data as JSON: /api/errors/91ab00b1add5192a. Report an issue: GitHub.