Test Strict Base64URL Decoding for iOS on a Cloud Mac

Test Strict Base64URL Decoding for iOS on a Cloud Mac

Test Strict Base64URL Decoding for iOS on a Cloud Mac

An API encodes a binary cursor as Base64URL, yet the iOS client occasionally accepts a corrupted cursor as valid data. The network is not necessarily to blame: permissive decoding, automatic padding, and ignored invalid characters can all turn input that should be rejected into bytes. On a cloud Mac, you can establish the decoding contract with a standalone Swift script, then move the same test cases into the app’s test target. There is no need to start a simulator to check encoding boundaries.

Define the accepted encoding first

This article accepts only nonempty, unpadded Base64URL: letters, digits, and the characters - and _. Spaces, line breaks, +, /, and = are not allowed. This is an explicit choice for the application’s API, not a rule that every protocol using Base64 should adopt.

Do not try to support standard Base64 and Base64URL through the same entry point by guessing the format. If a legacy API requires padding, provide a separate parsing entry point and test it independently. Otherwise, a client update could silently broaden the set of accepted inputs.

Length has structural constraints, too: the character count of unpadded text, divided by four, must leave a remainder of zero, two, or three. A remainder of one cannot form a valid encoding. The example limits input to 65536 UTF-8 bytes. That is an application policy for this test, not a protocol-defined limit.

Decoding only recovers bytes; it does not establish that the content is trustworthy. Tokens, cursors, and signature parameters still require checks of their structure, origin, and validity under the application’s business rules.

Enforce canonical representation by re-encoding

Save the following code as base64url-check.swift. Limit the input size first, check the alphabet next, and only then add padding internally. Do not use an option that ignores unknown characters, and do not strip whitespace beforehand.

import Foundation

enum DecodeFailure: Error {
    case size
    case alphabet
    case length
    case payload
    case noncanonical
}

func decodeBase64URL(
    _ text: String,
    maxEncodedBytes: Int = 65536
) throws -> Data {
    let count = text.utf8.count
    guard count > 0, count <= maxEncodedBytes else {
        throw DecodeFailure.size
    }

    let valid = text.utf8.allSatisfy { byte in
        (65...90).contains(byte)
        || (97...122).contains(byte)
        || (48...57).contains(byte)
        || byte == 45
        || byte == 95
    }
    guard valid else {
        throw DecodeFailure.alphabet
    }

    let remainder = count % 4
    guard remainder != 1 else {
        throw DecodeFailure.length
    }

    var padded = text
        .replacingOccurrences(of: "-", with: "+")
        .replacingOccurrences(of: "_", with: "/")
    padded += String(repeating: "=", count: (4 - remainder) % 4)

    guard let data = Data(base64Encoded: padded) else {
        throw DecodeFailure.payload
    }

    let canonical = data.base64EncodedString()
        .replacingOccurrences(of: "+", with: "-")
        .replacingOccurrences(of: "/", with: "_")
        .replacingOccurrences(of: "=", with: "")

    guard canonical == text else {
        throw DecodeFailure.noncanonical
    }
    return data
}

The final comparison is essential. The canonical representation of the single byte FF is _w. In _x, the unused trailing bits are nonzero, yet some decoders may still recover the same byte. Comparing the re-encoded result makes acceptance independent of how permissive the underlying decoder happens to be.

The function returns Data rather than converting directly to a string. Valid binary data is not necessarily valid UTF-8. Any higher-level code that needs text must check the encoding separately, rather than masking corrupted content with replacement characters.

Turn negative cases into executable checks

Append the following tests to the same file. A failed test throws an error, causing the script to exit with a nonzero status. This avoids relying on assertions that may be removed in optimized builds.

enum CheckFailure: Error {
    case mismatch
    case acceptedInvalid
}

let validCases: [(String, [UInt8])] = [
    ("AA", [0]),
    ("_w", [255]),
    ("-_8", [251, 255]),
    ("Zm9v", [102, 111, 111])
]

for (text, expected) in validCases {
    guard try decodeBase64URL(text) == Data(expected) else {
        throw CheckFailure.mismatch
    }
}

let invalidCases = [
    "", "A", "Zg==", "Zg\n", "+w", "/w", "_x", "中文"
]

for text in invalidCases {
    do {
        _ = try decodeBase64URL(text)
    } catch is DecodeFailure {
        continue
    }
    throw CheckFailure.acceptedInvalid
}

do {
    _ = try decodeBase64URL("AAAA", maxEncodedBytes: 3)
} catch is DecodeFailure {
    exit(0)
}
throw CheckFailure.acceptedInvalid

On a cloud Mac with command-line tools configured, run:

xcrun swift base64url-check.swift

On success, the script produces no output and exits with status zero. The _x case covers the trailing-bit check; the case containing a line break guards against someone adding automatic cleanup later; and the Chinese-character case confirms that the alphabet is validated as ASCII bytes, not by whether a character “looks like a letter.”

Check size boundaries and where decoding runs

Enforce the input limit before string replacement and decoding. Otherwise, oversized input will already have created additional copies. The largest canonical input allowed here decodes to 49152 bytes, but that is only an encoding-layer budget. Application fields should usually have smaller limits.

The string passed to the function is already in memory, so this check cannot replace a network response size limit. The receiving layer must limit the response body, the decoding layer must limit individual fields, and the structural parsing layer must limit collection sizes and nesting depth. Each of the three layers has a separate responsibility.

Add three more boundary cases:

If you use the decoded result as an identifier, do not accept a more permissive representation through other code paths. The API contract should specify a consistent representation for cache keys, deduplication keys, and signature inputs. Otherwise, the same content may be handled differently.

Add regression tests and keep diagnostics bounded

Once the standalone script passes, move the decoding function into a shared module and the test cases into the app’s unit test target. The command-line script is useful for quick reproduction, while tests in the app target catch differences in actual dependencies and build settings. Do not maintain separate implementations for the two over the long term.

Beyond the fixed cases, test byte arrays of different lengths by encoding, decoding, and comparing them byte for byte. Focus on the different remainders when the length is divided by three, because these determine how many unused trailing bits appear in the final encoding group. Then modify the last character to confirm that noncanonical representations cannot slip through.

Failure reports should contain only the error category, input length, and test case identifier. Do not log real tokens or complete cursors. Before release, verify that there is no automatic trimming, no ignoring of unknown characters, and no mixing of alphabets, and that structural validation takes place after decoding. The goal is not to make more inputs decode successfully, but to ensure that the API accepts only the representation both sides agreed on.

Frequently asked questions

Why re-encode the bytes after decoding?

Some decoders accept nonzero unused trailing bits. Re-encoding the bytes as canonical unpadded Base64URL and comparing the result with the original input rejects those alternate representations.

Does this decoder verify a token signature?

No. It validates the encoding and returns bytes only. Signature verification, expiration checks, and issuer validation require separate cryptographic and application-level checks.

Test your cloud Mac with your own pipeline

ArmRDP provides dedicated physical Mac minis, not virtual machines. Rent by the day, verify your toolchain and build results, then choose a rental term that works for your team.

Rent a Cloud Mac

Make configurations traceable and builds reproducible

Check the model, rental term, and connection steps, then hand off your tasks to a dedicated physical node.