Skip to content

fix(walletkit): bound the size of dApp requests and SVG icons - #1061

Draft
CassioMG wants to merge 8 commits into
cg-harden-dapp-inputfrom
cg-bound-dapp-content
Draft

CassioMG wants to merge 8 commits into
cg-harden-dapp-inputfrom
cg-bound-dapp-content

Conversation

@CassioMG

@CassioMG CassioMG commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR

Stacked on #1060. A dApp's request content and its icon are now bounded before the wallet parses or draws them:

  • Request size: a transaction XDR over 256 KB, an authorization entry over 64 KB, or a request with more than 100 authorized contract calls (or calls nested more than 20 deep) is rejected before the Blockaid scan and before any sheet opens. The dApp gets an error and the user sees a toast. The network's largest transaction is about 176 KB once base64-encoded, so no transaction it accepts is turned away.
  • Sign message: a JSON message is indented for display only while the result stays under 20,000 characters. Past that, it's shown exactly as sent.
  • SVG icons: dApp icons keep working as SVG, but the wallet now downloads them itself: https: only, at most 100 KB (the download stops as soon as it passes the cap), with a 5-second timeout. It draws one only if it has at most 2,000 elements from a list of plain drawing elements, with no DOCTYPE or entities, and no mask or clip path applied inside a mask or clip path. Anything else shows the dApp's initials.

Lines changed

Kind Files Lines added Lines removed
Source code 5 450 14
Tests 6 590 6
Other (translations) 2 6 0
Total 13 1,046 20

Only this PR's own changes, not those of the PRs it's stacked on.

Screenshots (iPhone SE simulator on iOS 27.2, Pixel 10 Pro emulator)

Check iOS Android
A transaction XDR over 256 KB: rejected before any sheet, with a toast
An auth entry with 101 contract calls: rejected the same way
A 10 KB JSON message nested 510 levels deep: shown as sent
The largest auth entry the caps allow (65,536 characters): the sheet opens
An auth entry with 100 calls: its details screen
A plain SVG icon: drawn
An SVG icon using <use>: initials
An SVG icon whose mask masks itself: initials
Implementation details (for agents)

What changed:

  • src/helpers/walletKitValidation.ts:

    • New caps: SIGN_XDR_MAX_LENGTH (262,144 base64 characters), SIGN_AUTH_ENTRY_MAX_LENGTH (65,536), MAX_AUTH_INVOCATIONS (100, across all of a request's authorization trees) and MAX_AUTH_INVOCATION_DEPTH (20, a root call is depth 1). SIGN_MESSAGE_MAX_BYTES stays 10,240.
    • validateSignXdrLength, validateSignAuthEntryLength, getTransactionAuthInvocations and validateInvocationTrees (an explicit stack, so a deep tree can't overflow the call stack).

    export const MAX_AUTH_INVOCATIONS = 100;
    export const MAX_AUTH_INVOCATION_DEPTH = 20;
    /**
    * Max length, in base64 characters, of the transaction XDR in sign_xdr and
    * sign_and_submit_xdr requests. The network's largest transaction is about
    * 176 KB once base64-encoded, so no transaction it accepts is turned away.
    */
    export const SIGN_XDR_MAX_LENGTH = 262_144;
    /**
    * Max length, in base64 characters, of the HashIdPreimage in sign_auth_entry
    * requests. Real authorization entries are a few KB.
    */
    export const SIGN_AUTH_ENTRY_MAX_LENGTH = 65_536;
    /**
    * Max UTF-8 byte length for sign_message content. SEP-53 imposes no size
    * limit (and the browser extension enforces none) — this is a sanity cap
    * against absurd WalletConnect payloads, sized to comfortably fit the JSON
    * payloads dApps actually sign (see stellar/freighter-mobile#957).
    */
    export const SIGN_MESSAGE_MAX_BYTES = 10240;
    /**
    * Longest indented JSON the sign-message view will show. Indentation can make
    * deeply nested input hundreds of times longer than it was sent; past this,
    * the message is shown as sent.
    */
    export const SIGN_MESSAGE_PRETTY_MAX_LENGTH = 20_000;

    export function validateInvocationTrees(
    roots: xdr.SorobanAuthorizedInvocation[],
    ): ValidationResult<true> {
    const tooMany: ValidationError = {
    valid: false,
    errorKey: ValidationErrorKeys.TOO_MANY_INVOCATIONS,
    };
    const pending = roots.map((invocation) => ({ invocation, depth: 1 }));
    let visited = 0;
    while (pending.length > 0) {
    const next = pending.pop();
    if (!next) {
    break;
    }
    visited += 1;
    if (next.depth > MAX_AUTH_INVOCATION_DEPTH) {
    return tooMany;
    }
    next.invocation.subInvocations.forEach((invocation) =>
    pending.push({ invocation, depth: next.depth + 1 }),
    );
    // Every pending call will be counted too.
    if (visited + pending.length > MAX_AUTH_INVOCATIONS) {
    return tooMany;
    }
    }
    return { valid: true, value: true };
    }
    /**
    * Returns the root invocation of every authorization entry in a transaction's
    * invokeHostFunction operations, or null when the XDR doesn't parse (the
    * details view then reports it as undisplayable).
    */
    export function getTransactionAuthInvocations(
    xdrParam: string,
    networkPassphrase: string,
    ): xdr.SorobanAuthorizedInvocation[] | null {
    try {
    const parsed = TransactionBuilder.fromXDR(xdrParam, networkPassphrase);
    const transaction =
    parsed instanceof FeeBumpTransaction ? parsed.innerTransaction : parsed;
    return transaction.operations
    .filter(
    (op): op is Operation.InvokeHostFunction =>
    op.type === "invokeHostFunction",
    )
    .flatMap((op) => op.auth ?? [])
    .map((entry) => entry.rootInvocation);
    } catch {
    return null;
    }
    }
    /**
    * Validates sign_xdr and sign_and_submit_xdr length against SIGN_XDR_MAX_LENGTH.
    */
    export function validateSignXdrLength(
    xdrParam: string,
    ): ValidationResult<string> {
    if (xdrParam.length > SIGN_XDR_MAX_LENGTH) {
    return { valid: false, errorKey: ValidationErrorKeys.XDR_TOO_LARGE };
    }
    return { valid: true, value: xdrParam };
    }
    /**
    * Validates sign_auth_entry length against SIGN_AUTH_ENTRY_MAX_LENGTH.
    */
    export function validateSignAuthEntryLength(
    entryXdr: string,
    ): ValidationResult<string> {
    if (entryXdr.length > SIGN_AUTH_ENTRY_MAX_LENGTH) {
    return {
    valid: false,
    errorKey: ValidationErrorKeys.AUTH_ENTRY_TOO_LARGE,
    };
    }
    return { valid: true, value: entryXdr };
    }

  • src/providers/WalletKitProvider.tsx: the XDR and auth-entry prevalidation runs the length check, then the call-count and depth check, before the Blockaid scan. failRequestValidation now logs a warning (method and reason only), rejects with a .catch, and drops the request as the current one, so a rejected request is never parsed for display.

    const failRequestValidation = (
    sessionRequest: WalletKitSessionRequest,
    errorKey: ValidationErrorKey,
    ) => {
    logger.warn(
    "WalletKitProvider",
    "Rejected a dApp request that failed validation",
    {
    method: (
    sessionRequest.params as { request?: { method?: string } } | undefined
    )?.request?.method,
    errorKey,
    },
    );
    showToast({
    title: t("walletKit.invalidRequestTitle"),
    message: t(errorKey),
    variant: "error",
    });
    rejectSessionRequest({
    sessionRequest,
    message: t(errorKey),
    }).catch((error: unknown) => {
    logger.warn(
    "WalletKitProvider",
    "Failed to reject a dApp request that failed validation",
    error,
    );
    });
    // processSessionRequest set this request as the current one before
    // validating it. Dropping it in the same update means a rejected payload
    // is never parsed or walked for display.
    setRequestEvent(null);
    clearEvent();
    isProcessingRequestRef.current = false;
    };
    /**
    * Validates the xdr param for sign_xdr and sign_and_submit_xdr requests.
    * @returns the xdr string if valid, or null if invalid (rejection handled)
    */
    const prevalidateSignXdr = (
    sessionRequest: WalletKitSessionRequest,
    ): string | null => {
    const xdrParam = (
    sessionRequest.params as
    | { request?: { params?: { xdr?: unknown } } }
    | undefined
    )?.request?.params?.xdr;
    const result = validateSignXdrContent(xdrParam);
    if (!result.valid) {
    showToast({
    title: t("walletKit.invalidRequestTitle"),
    message: t(result.errorKey),
    variant: "error",
    });
    rejectSessionRequest({
    sessionRequest,
    message: t(result.errorKey),
    });
    clearEvent();
    isProcessingRequestRef.current = false;
    return null;
    }
    const lengthResult = validateSignXdrLength(result.value);
    if (!lengthResult.valid) {
    failRequestValidation(sessionRequest, lengthResult.errorKey);
    return null;
    }
    // A transaction under the size cap can still carry thousands of tiny
    // authorized calls, and the details view renders one card per call. An
    // XDR that doesn't parse goes on and is shown as undisplayable.
    const authInvocations = getTransactionAuthInvocations(
    lengthResult.value,
    networkDetails.networkPassphrase,
    );
    if (authInvocations) {
    const treeResult = validateInvocationTrees(authInvocations);
    if (!treeResult.valid) {
    failRequestValidation(sessionRequest, treeResult.errorKey);
    return null;
    }
    }
    return lengthResult.value;
    };

  • src/components/screens/WalletKit/DappMessageDisplay.tsx: falls back to the message as sent when the indented JSON would pass SIGN_MESSAGE_PRETTY_MAX_LENGTH (20,000). Hermes refuses JSON nested deeper than 510 levels, so the worst case that still parses is a 10 KB message 510 levels deep, about 5.2M characters once indented, built in about 13 ms on a Mac and then dropped.

    const indentedMessage = isJson
    ? JSON.stringify(JSON.parse(message), null, 2)
    : message;
    const displayMessage =
    indentedMessage.length <= SIGN_MESSAGE_PRETTY_MAX_LENGTH
    ? indentedMessage
    : message;

  • src/helpers/boundedSvg.ts (new):

    • fetchBoundedSvg(url) downloads with XMLHttpRequest and aborts once the text received passes 100 KB. On Android, React Native reports a stale loaded for text responses, so the length of responseText counts too. It resolves null on a timeout, an error, a non-2xx status or a URL that isn't https:.
    • isRenderableSvg(text) checks the length, rejects DOCTYPE and ENTITY declarations, counts opening tags against MAX_SVG_ELEMENTS (2,000) and the element allowlist, then parses the file with react-native-svg's own parse() and walks its element tree: a mask or clip-path reference on a <mask> or <clipPath>, or on anything inside one, is refused. On iOS a mask that masks itself recursed natively until the app aborted.

    const ALLOWED_SVG_ELEMENTS = new Set([
    "svg",
    "g",
    "path",
    "rect",
    "circle",
    "ellipse",
    "line",
    "polyline",
    "polygon",
    "defs",
    "linearGradient",
    "radialGradient",
    "stop",
    "clipPath",
    "mask",
    "title",
    "desc",
    "metadata",
    "text",
    "tspan",
    "style",
    ]);
    /** An element as react-native-svg's parser reads it, before it's drawn. */
    interface SvgNode {
    tag: string;
    props: { [prop: string]: unknown };
    children: (SvgNode | string)[];
    }
    /** Whether a node is masked or clipped, by an attribute or its inline style. */
    const isMaskedOrClipped = ({ props }: SvgNode): boolean => {
    const style = props.style as { [prop: string]: unknown } | undefined;
    return Boolean(
    props.mask || props.clipPath || style?.mask || style?.clipPath,
    );
    };
    /**
    * Whether a `<mask>` or `<clipPath>` element, or anything inside one, is itself
    * masked or clipped. react-native-svg follows a mask into its content with no
    * loop check, so a mask whose content uses the same mask, directly or through
    * a clip path, recurses until the app is killed. Walks with an explicit stack.
    */
    const hasNestedMaskOrClipPath = (root: SvgNode): boolean => {
    const pending = [{ node: root, inMaskOrClipPath: false }];
    while (pending.length > 0) {
    const { node, inMaskOrClipPath } = pending.pop()!;
    const inside =
    inMaskOrClipPath || node.tag === "mask" || node.tag === "clipPath";
    if (inside && isMaskedOrClipped(node)) {
    return true;
    }
    node.children.forEach((child) => {
    if (typeof child !== "string") {
    pending.push({ node: child, inMaskOrClipPath: inside });
    }
    });
    }
    return false;
    };
    /**
    * Parses the SVG with react-native-svg's own parser, the one SvgXml draws
    * from, and checks its element tree before the parser turns it into React
    * elements. False if it doesn't parse or has a nested mask or clip path.
    */
    const parsesWithoutNestedMaskOrClipPath = (text: string): boolean => {
    let safe = false;
    try {
    parse(text, (ast) => {
    safe = !hasNestedMaskOrClipPath(ast as unknown as SvgNode);
    return ast;
    });
    } catch {
    return false;
    }
    return safe;
    };
    /**
    * Whether an SVG's text is a plain icon that is cheap to draw: at most
    * MAX_SVG_BYTES long and MAX_SVG_ELEMENTS elements, with no DOCTYPE or entity
    * declaration, only allowed elements, parseable by react-native-svg, and with
    * no mask or clip path that is masked or clipped itself.
    */
    export const isRenderableSvg = (text: string): boolean => {
    if (text.length > MAX_SVG_BYTES) {
    return false;
    }
    if (/<!(DOCTYPE|ENTITY)/i.test(text)) {
    return false;
    }
    const elements = text.match(/<[A-Za-z][\w:.-]*/g) ?? [];
    if (elements.length > MAX_SVG_ELEMENTS) {
    return false;
    }
    if (!elements.every((tag) => ALLOWED_SVG_ELEMENTS.has(tag.slice(1)))) {
    return false;
    }
    return parsesWithoutNestedMaskOrClipPath(text);
    };
    /**
    * Downloads a remote SVG icon with a hard size cap and a timeout, and returns
    * its text only if it is a plain icon (see isRenderableSvg). Only `https:`
    * URLs are fetched. The download is aborted as soon as it passes
    * MAX_SVG_BYTES, so an oversized file is never held in memory in full.
    *
    * @param url - The icon's URL
    * @returns The SVG text, or null when it can't be drawn safely
    */
    export const fetchBoundedSvg = (url: string): Promise<string | null> =>
    new Promise((resolve) => {
    if (!url.toLowerCase().startsWith("https://")) {
    resolve(null);
    return;
    }
    let settled = false;
    const finish = (value: string | null) => {
    if (!settled) {
    settled = true;
    resolve(value);
    }
    };
    const request = new XMLHttpRequest();
    request.open("GET", url);
    request.responseType = "text";
    request.timeout = SVG_FETCH_TIMEOUT_MS;
    request.onprogress = (event) => {
    // For text responses React Native on Android reports a stale `loaded`
    // (0, or -1 for a decompressed body), so the text received so far counts too.
    if (Math.max(event.loaded, request.responseText.length) > MAX_SVG_BYTES) {
    request.abort();
    finish(null);
    }
    };
    request.onload = () => {
    const text = request.status === 200 ? request.responseText : "";
    finish(text && isRenderableSvg(text) ? text : null);
    };
    request.onerror = () => finish(null);
    request.ontimeout = () => finish(null);
    request.onabort = () => finish(null);
    request.send();
    });

  • src/components/sds/App/index.tsx: remote SVG icons render through BoundedSvgIcon (fetch, check, then SvgXml) instead of SvgUri, protocol icons included. While it loads, the icon's box stays empty; on any failure it shows the initials.

    /**
    * Draws a remote SVG icon only after a bounded download (see fetchBoundedSvg),
    * leaving the sized box empty while it loads. Any failure calls `onFailed`, so
    * the caller shows initials instead.
    */
    const BoundedSvgIcon: React.FC<{
    uri: string;
    size: AppSize;
    onFailed: () => void;
    }> = ({ uri, size, onFailed }) => {
    const [xml, setXml] = useState<string | null>(null);
    useEffect(() => {
    let active = true;
    setXml(null);
    fetchBoundedSvg(uri).then((text) => {
    if (!active) {
    return;
    }
    if (text) {
    setXml(text);
    } else {
    onFailed();
    }
    });
    return () => {
    active = false;
    };
    }, [uri, onFailed]);
    if (!xml) {
    return null;
    }
    return (
    <SvgXml
    xml={xml}
    width={sizeMap[size]}
    height={sizeMap[size]}
    preserveAspectRatio="xMidYMid meet"
    onError={onFailed}
    />
    );
    };

  • i18n (en, pt): walletKit.errorXdrTooLarge, walletKit.errorAuthEntryTooLarge and walletKit.errorTooManyInvocations.

Verification:

  • yarn test: 353 suites pass, 5,291 tests pass and 6 are skipped. New tests cover each cap at and past its limit, the provider rejecting before the scan and never preparing a rejected request for display, the as-sent fallback, the SVG loader (abort at the cap with the stale Android progress, timeout, http: refused, each blocked element, self-referencing masks and clip paths, a file that doesn't parse) and the icon component's initials fallback.
  • yarn lint:ts passes, and ESLint and Prettier pass on every changed file.
  • Dev builds on an iPhone SE (3rd generation) iOS 27.2 simulator and a Pixel 10 Pro emulator, driven by a local WalletConnect test client (screenshots above):
    • a 262,145-character transaction XDR, a transaction whose authorizations hold 101 calls, and an auth entry with 101 calls: each rejected with its toast, and the dApp gets the error;
    • a 10 KB message nested 510 levels deep: shown as sent, and the box scrolls;
    • the largest requests the caps allow (an auth entry of 65,536 characters, an auth entry and a 261,692-character transaction with 100 calls each): the sheets open, the details screens open and scroll, and the app stays up. On iOS my test flow couldn't tap the transaction's "Transaction details" row, so that one screen was checked on Android only;
    • SVG icons: a plain one draws; one using <use>, a 1.1 MB sprite sheet and a self-masking one show initials, and the app stays up;
    • raster icons: a PNG and a JPEG of 16,000 × 16,000 pixels, a GIF with an oversized canvas and a 19 MB JPEG of noise. The app stays up and its memory stays flat on both devices, so nothing is built for raster icons, as planned.

Follow-ups / out of scope:

  • On iOS the 5-second timeout is an idle timeout, so a host that keeps sending a few bytes keeps the icon box blank instead of showing initials. The download is still capped at 100 KB.
  • The allowlist turns some ordinary icons into initials: Inkscape's namespaced elements (sodipodi:, rdf:) and Illustrator's DOCTYPE.
  • The depth limit reuses the "too many contract calls" message.
  • docs/walletconnect-rpc-methods.md doesn't list the new caps.

🤖 Generated with Claude Code

CassioMG and others added 8 commits November 11, 2026 14:59
…p requests

stellar_signXDR and stellar_signAndSubmitXDR reject an xdr over 256 KB of base64, above the network's largest transaction, and stellar_signAuthEntry rejects an entry over 64 KB, before any scan or display.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… in dApp requests

stellar_signAuthEntry, stellar_signXDR and stellar_signAndSubmitXDR now reject a request whose authorization trees hold more than 100 contract calls or go deeper than 20 levels, before any scan or display; the signing screens render one card per call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ndenting them

Indenting deeply nested JSON can make it hundreds of times longer than it was sent; past 20,000 characters the message is now shown as sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…checked loader

SVG icons are now downloaded with a 100 KB cap that aborts mid-stream, a 5 s timeout and https only, and drawn with SvgXml only if they hold at most 2,000 plain drawing elements; anything else shows the app's initials.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
React Native on Android sends a stale byte count with every chunk of a
text response, so the progress check never passed the cap there, and a
large icon was held in full until the final length check turned it
away. The check now also counts the text received so far.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A request turned away by a content cap stayed set as the current
request until the next one arrived, so its payload was still parsed and
walked for display. It's now dropped in the same update that rejects
it. The rejection is logged as a warning, like other dApp input the
wallet turns away, and a failure to answer the dApp is caught.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The nested message is about 1 KB as sent and about 500 KB once
indented, not 10 KB and 5 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
react-native-svg follows a mask into its content with no loop check, so
an icon whose mask content used the same mask recursed until the app
was killed (seen on the iOS simulator). The loader now parses the icon
with react-native-svg's own parser and draws it only if nothing inside
a <mask> or <clipPath> is itself masked or clipped, by an attribute or
an inline style. Icons that use masks and clip paths the usual way still
draw. An icon that doesn't parse now shows initials too, instead of
failing while it's drawn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Android Emulator preview APK is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-b72cb25663cd7396f0f6
Backend: sandbox (CassioMG). SDF collaborators only — install instructions in the release description.

@github-actions

Copy link
Copy Markdown
Contributor

iOS Simulator preview build is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-315fad196769806590f2
Backend: sandbox (CassioMG). SDF collaborators only — install instructions in the release description.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant