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
- 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.
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
- 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
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
- Bookmark query exceeds maximum scan window of ${MAX_SESSION_
- Invalid bookmark query
- Invalid limit query
- Limit query must be between 1 and 100
- Invalid limit query
AI-assisted analysis of Budibase/budibase@a81a902e9a (2026-08-29).
Data as JSON: /api/errors/91ab00b1add5192a.
Report an issue: GitHub.