Skip to content

Aaronb/vendor base 64 poc#8578

Open
7188ce06 wants to merge 3 commits into
clerk:mainfrom
7188ce06:aaronb/vendor-base-64-poc
Open

Aaronb/vendor base 64 poc#8578
7188ce06 wants to merge 3 commits into
clerk:mainfrom
7188ce06:aaronb/vendor-base-64-poc

Conversation

@7188ce06
Copy link
Copy Markdown

Proposal: Address customer-side supply-chain exposure in published Clerk SDKs

Summary

  1. Two classes of supply-chain attack against Clerk customers. Both route through a runtime npm dependency that a published @clerk/* SDK declares as an external, which the customer's package manager fetches fresh from npm at install time.

    • Malicious release through the upstream maintainer trust boundary — a maintainer of a dep we depend on publishes a malicious version. This is the umbrella for several real-world precedents: maintainer account compromise (e.g. ua-parser-js 2021), hostile ownership transfer (event-stream 2018), maintainer self-sabotage (colors.js / faker.js 2022), and social engineering into commit authority (xz-utils 2024). Customers' resolvers pick up the new version on next install and the malicious code runs in their app.
    • Registry-level same-version substitution — the npm registry (or a mirror, CDN, or cache in front of it) serves substituted bytes for an existing version while the version number itself is unchanged. Requires a bypass of npm's immutability invariant (registry-infra compromise, npm-internal account compromise, cache poisoning, etc.) — not simple publisher-side abuse, because npm permanently retires a version number once it's been used. Even so, the customer's first install records the malicious hash as trusted; subsequent installs with --frozen-lockfile "verify" against the now-poisoned hash and reproduce the compromise.
  2. Three options Clerk could take per affected dep.

    • Vendor: copy the upstream source byte-for-byte into the Clerk repo, remove the npm dep from package.json. The published Clerk tarball ships the source inline.
    • Build-time bundle (tsup/tsdown --noExternal): keep the dep in package.json, but configure the bundler to inline its bytes into the published dist/ artifact rather than emit a runtime require() for it. Cheap to apply per-dep on packages that already bundle (e.g. @clerk/shared); more invasive on packages that currently transpile per-file with bundle: false (e.g. @clerk/expo), where adopting it means changing the build mode.
    • Replace with a platform primitive: delete the dep entirely; rewrite the consumer in terms of crypto.subtle, Buffer.from, native atob, etc. (Only viable when the primitive exists and behaves equivalently in every consumer runtime.)

    (A fourth option — status quo — is documented for completeness in the body. None of Clerk's existing supply-chain hardening transits to customer installs, so status quo means accepting both chains against every external runtime dep.)

  3. POC. Branch aaronb/vendor-base-64-poc implements the vendor option for base-64 in @clerk/expo, end-to-end. Includes an npm pack + clean-fixture install that empirically verifies base-64 no longer lands in customer node_modules after the change. The verification methodology and per-dep reasoning are in the branch's README.

Why Clerk's lockfile doesn't protect customers

The load-bearing premise of this proposal is that none of Clerk's existing supply-chain hardening transits to customer installs. Quickly:

  • pnpm-lock.yaml lives at the repo root and is consulted by pnpm install inside the Clerk monorepo. It pins exact versions and sha512 integrity hashes for every dep in Clerk's dev/CI environment.
  • It is not included in any published @clerk/* npm tarball. Lockfiles aren't part of npm's publish format, and shipping one would actively break customer installs (their package manager has its own lockfile and dep tree).
  • When a customer runs pnpm install @clerk/expo (or npm install, yarn add, expo install), their package manager reads the published @clerk/expo's package.json, walks its dependencies (the declared version ranges), and resolves every entry against the npm registry at their install time. Clerk's lockfile is not in that path.
  • Clerk's minimumReleaseAge (configured in renovate.json5) gates only Renovate-opened PRs against the Clerk repo. It does not transit to customer Renovate configs.
  • Clerk's pnpm.overrides and onlyBuiltDependencies allowlist apply only to installs that read Clerk's package.json — i.e., installs of Clerk, not installs that depend on Clerk.

The net effect: every defensive control Clerk has built around its own dep tree protects Clerk's CI and Clerk's developers. None of it follows the published @clerk/* package into a customer's node_modules. The customer's defenses are entirely their own, computed against the registry's state at their install time.

This is why the attack chains below are not defended by any Clerk-side hardening that doesn't change what ships in the tarball.

The two attack chains

Both attacks compromise a Clerk customer's running application via a dependency that Clerk's published SDK declares as a runtime external. The examples below use base-64 in @clerk/expo because that's the worked POC case, but the same shape applies to any external runtime dep in any published Clerk SDK.

Chain 1 — Malicious release through the upstream maintainer trust boundary

Premise: @clerk/expo declares "base-64": "^1.0.0" (caret-ranged). Mathias Bynens is the sole base-64 npm publisher; whoever has publish authority for that package can ship malicious code at any time and customers will pick it up.

  1. An adversary obtains the ability to publish a new base-64 version under Mathias's publish authority. The path varies (see precedents below); the common shape is that the existing publish channel is used to ship a release the original author wouldn't have signed off on.
  2. The adversary publishes base-64@1.0.1 to npm. The tarball contains a malicious encode and decode that — alongside their normal output — exfiltrate inputs to an attacker-controlled endpoint (or do anything else; pick your payload).
  3. A Clerk customer runs pnpm install @clerk/expo (or npm, yarn, expo install) on their dev machine, in CI, or as part of a fresh deployment.
  4. The customer's resolver evaluates ^1.0.0 against the npm registry's current versions, picks 1.0.1 (the new latest in range), and records its integrity hash in the customer's lockfile.
  5. At runtime, @clerk/expo's polyfill imports encode/decode from the resolved base-64@1.0.1 and assigns them to global.btoa/global.atob.
  6. From that point on, every btoa() or atob() call anywhere in the customer's app — including third-party libraries the customer uses, payment SDK calls, OAuth flows, Clerk's own runtime — routes through the attacker's encode/decode.

Historical precedents in this class — malicious releases pushed through the existing upstream maintainer channel — cover several distinct mechanisms, all reaching customers the same way:

  • Maintainer account compromise: ua-parser-js (2021) — credentials phished, three malicious versions published.
  • Hostile ownership transfer: event-stream (2018) — original maintainer handed off to a contributor who later published a malicious version pulling flatmap-stream.
  • Maintainer self-sabotage: colors.js / faker.js (2022) — original maintainer deliberately published broken/malicious versions.
  • Social engineering into commit/release authority: xz-utils (2024) — years-long cultivation of a co-maintainer position, culminating in malicious commits to a new release.

What ties them together is the trust boundary: customers' resolvers trust whatever the upstream's publish authority shipped, regardless of how that publish came to exist. Chain 2 below adds a residual gap for first-install customers that exact-pinning doesn't close — but Chain 1 alone is sufficient justification on the historical record.

Chain 2 — Registry-level same-version substitution

Premise: even with an exact pin ("base-64": "1.0.0"), the customer's resolver still fetches 1.0.0 from the npm registry on first install.

  1. An attacker causes the npm registry — or a mirror, CDN, or caching layer in front of it — to serve substituted bytes for an existing version. Possible mechanisms include compromise of npm's registry infrastructure, compromise of an npm-internal account with publish/admin authority, cache poisoning on a CDN edge, or compromise of an enterprise mirror that downstream resolvers point at. (npm's standard policy prevents the simpler "unpublish-then-republish at the same version" path: once a name@version is used, that version number is permanently retired even after unpublish. So Chain 2 requires a bypass of the immutability invariant, not abuse of the published-version-management flow.)
  2. No version number changes. No Renovate PR. No "new version available" signal anywhere.
  3. A Clerk customer runs pnpm install @clerk/expo on a fresh machine (new CI runner, new contributor's laptop, fresh deployment image). They have no prior lockfile for this project.
  4. The resolver fetches base-64@1.0.0 from the registry, receives the substituted bytes, computes their sha512, and records that hash in the customer's newly-created lockfile.
  5. Subsequent installs by the customer — even with --frozen-lockfile — "verify" the bytes against the recorded hash. They match (because the recorded hash is for the malicious bytes). The customer's CI passes integrity checks. The compromise reproduces forever.
  6. Detection requires either (a) an independent integrity comparison against archived registry data, or (b) a customer with a pre-substitution lockfile somewhere noticing a hash mismatch and reporting it. Neither is automatic.

Historical precedents in this class are rarer in the public record (the most relevant is npm's 2022 access-token compromise incident, which briefly allowed publishing impersonated versions). The consequence is that for an unknown number of incidents that DID happen this way, nobody knew — there's no version-bump diff to spot. Chain 2 is the weaker case on its own — it should be read as the additional residual gap that even exact-pinning leaves open, not as the primary justification for vendoring. Chain 1 is the primary justification.

What each end-user action looks like in each chain

End-user action Chain 1 (account compromise) Chain 2 (registry substitution)
First npm install @clerk/expo on a fresh machine Resolver picks base-64@1.0.1 (malicious). Lockfile records its hash. App runs malicious btoa/atob. Resolver picks base-64@1.0.0. Registry returns malicious bytes. Lockfile records the malicious hash as trusted. App runs malicious btoa/atob.
Subsequent npm install --frozen-lockfile with existing lockfile (pre-compromise) Lockfile pins 1.0.0's original hash. Reinstalling fetches 1.0.0, hashes match, safe. Hash mismatch detected: install errors loudly. Customer is alerted (their build breaks).
npm update base-64 Updates lockfile to point at 1.0.1 (malicious). Subsequent installs use the new hash. Updates lockfile to point at registry's current 1.0.0 bytes. If registry now serves malicious bytes, the new hash is the malicious hash.
Renovate PR that bumps @clerk/expo's version New @clerk/expo tarball, which still declares "base-64": "^1.0.0". Lockfile reconciliation may pick up 1.0.1 if not already pinned. No effect — @clerk/expo tarball's base-64 declaration is unchanged; the customer still fetches 1.0.0 whose bytes are now malicious.

Options for defense

Each option below is a per-dep decision — Clerk can mix and match across the dep tree. The right answer for base-64 in @clerk/expo is not necessarily the right answer for, say, dequal in @clerk/shared. The dev team should weigh trade-offs case by case.

Closes Chain 1? Closes Chain 2 (first install)? Closes Chain 2 (existing lockfile)? Operational cost
Status quo (Option 4) ✅ hash mismatch caught on second install None
Exact-pin in package.json (mentioned for completeness; not a full option) One line per dep, no maintenance
Vendor (Option 1) ✅ N/A — no registry fetch Per-vendor: README, parity test, refresh procedure, ESLint guard, stale-vendor watcher.
Build-time bundle (Option 2) ✅ N/A — no registry fetch Two-part: (a) noExternal: entry in tsup/tsdown config, AND (b) move the dep from dependencies to devDependencies so the published manifest no longer declares it. Renovate continues to manage upgrades as today.
REPLACE with platform primitive (Option 3) ✅ N/A — no registry fetch One-time rewrite cost; ongoing maintenance is now Clerk's.

Option 1: Vendor the dep

Copy the upstream source byte-for-byte into Clerk's tree under packages/<consumer>/src/vendor/<name>/upstream/, remove the npm dep from package.json (or move it to devDependencies for parity testing). The published Clerk tarball ships the source inline; the customer's resolver never walks to the registry for that dep.

Pros

  • Severs the upstream maintainer's account from the customer-install trust set, for both chains.
  • The committed vendor/ directory is grep-able, audit-friendly, and clearly labeled — downstream security auditors can inspect what shipped.
  • Forces source-diff review on every refresh because the diff IS the source. Reviewers can't LGTM a "version bump" without reading the changed lines.

Cons

  • Source committed to the Clerk git repo (small bytes per vendor; adds up across many).
  • Per-vendor maintenance overhead: README documenting rationale + refresh procedure, parity test against upstream, ESLint guard against direct imports, entry in a VENDORS.json.
  • Refreshes are manual and deliberate — easy to forget; needs a stale-vendor CI watcher to surface upstream releases / CVEs.
  • Pre-existing precedent in Clerk: clerk_go/api/scimgateway/imported/ (Go-side lift-and-shift) and packages/nextjs/src/vendor/crypto-es.js (sync crypto for Next middleware).

When it's the right answer

  • Security-critical paths where source-level review at every upgrade is genuinely valuable (e.g. polyfills that force-replace runtime primitives, webhook signature verification, crypto).
  • Deps that can't be cleanly REPLACEd because the platform primitive isn't available in every consumer runtime.

Option 2: Build-time bundling (tsup --noExternal / tsdown --noExternal)

Two changes per dep: (a) configure the bundler to inline the dep's bytes into the published dist/ artifact rather than emit a runtime require() for it, AND (b) move the dep from dependencies to devDependencies so the published package.json no longer declares it as a runtime external. Both are required. Without (a), the bundler still emits a require() that the customer's resolver walks. Without (b), the customer's resolver walks the dependencies declaration regardless of whether the runtime code actually require()s it. With both, the dep ships inlined in the tarball and the customer never fetches it.

Pros

  • No source committed to Clerk's git tree. No VENDORS.json, no per-vendor README, no refresh ritual.
  • Renovate continues to handle upgrades as today — the dep stays in package.json (under devDependencies), version bumps land via the existing PR flow.
  • For packages already shipping bundled artifacts (e.g. @clerk/shared builds with tsdown and unbundle: false), configuration is per-package and minimal: one bundler-config entry + one package.json move per dep.
  • Clerk's own install still pulls the upstream package normally, so Clerk-side hardening (minimumReleaseAge, integrity-pinned lockfile) continues to apply during Clerk's own dev/CI.

Cons

  • Renovate auto-bumps land via PR review of "version + lockfile delta," not source diff. Reviewers can LGTM a malicious upgrade if they don't drill into the upstream changes.
  • Inlined bytes are harder for downstream auditors to inspect — they read a minified dist/index.js to find the inlined module, vs. a labeled vendor/ directory.
  • Bundle size grows by the dep's size (same as vendoring on the published-tarball side).
  • Forgetting step (b) silently defeats the defense — the customer still fetches the upstream while ALSO getting the inlined bytes, doubling the surface instead of closing it. Worth a CI check that flags any dep listed in noExternal: that also appears in dependencies.
  • Not a one-line lever on every Clerk package. Some Clerk packages currently build with bundle: false (per-file transpilation rather than bundling) — notably @clerk/expo (see packages/expo/tsup.config.ts). Switching one dep to noExternal there means changing the build mode to bundle the consuming file, which has its own knock-on effects (different output shape, possibly different code-splitting / lazy-load semantics, source-map changes). For those packages, the operational cost of Option 2 starts looking closer to Option 1's, not cheaper.

When it's the right answer

  • Most deps. If the upgrade-review attention isn't load-bearing for security, noExternal achieves the customer-side closure with materially less ceremony than vendoring.

Option 3: REPLACE the dep with platform primitives

Delete the npm dep entirely. Rewrite the consumer code in terms of platform-available APIs: crypto.subtle, crypto.randomUUID, Buffer.from, navigator.clipboard, native atob/btoa, etc. The replacement is bug-for-bug different from the upstream — that's the point — and is treated as first-party Clerk code with its own tests and code review.

Pros

  • Eliminates the dep entirely. Customer doesn't need it; Clerk doesn't ship it. Both chains closed for that dep.
  • Often a strict win on bundle size (platform primitives don't ship userland source at all).
  • No vendor/bundle ceremony.

Cons

  • One-time engineering cost to rewrite.
  • Behavior is now Clerk's responsibility — edge cases may regress and need ongoing fix attention.
  • Requires verifying the platform primitive exists and behaves equivalently in every consumer target runtime. Often there's no clean answer across Node + browser + Workers + RN/Hermes.

When it's the right answer

  • The replacement is small, runtime-uniform, and the upstream's edge-case behavior is well-defined enough that Clerk can mirror it.
  • Good candidate shape: fast-sha256crypto.subtle (uniform across Node 18+, browsers, Workers) — the platform primitive is universally available and the upstream's exposed surface is small enough to mirror cleanly.
  • Not a good candidate when the platform primitive isn't available in every consumer runtime, or when the polyfill exists specifically to work around a known bug in the platform primitive. The POC documents one such case (base-64 in @clerk/expo) in detail.

Option 4: Status quo

Do nothing. Customers' resolvers continue to fetch deps fresh from npm at install time. Both attack chains remain open against every external runtime dep in every published @clerk/* package.

This is the option Clerk is on today. It's a defensible choice if the dev team judges that:

  • The cost of (1), (2), or (3) — across the dep set worth addressing — outweighs the residual risk.
  • The customer threat model doesn't warrant Clerk-side action; customers should harden their own installs.
  • The right defense is upstream-of-Clerk: encourage npm provenance adoption, push for upstream-engine fixes that obviate problematic polyfills, etc.

Listing status quo as Option 4 explicitly is meant to ensure the team chooses it knowingly, not by default.

Highest-priority candidates to harden

Short list of the deps with the strongest customer-side exposure case, in rough priority order. Each row identifies the dep, which @clerk/* package pulls it as a customer-facing external, the maintainer-concentration signal, and the suggested option. A longer analysis covering ~30 candidates surfaced by the dependency audit is available on request.

Dep Consumer Maintainers Customer-side surface Suggested option
base-64 @clerk/expo 1 (mathias) Polyfilled global.btoa/global.atob — compromised code becomes the base64 implementation for every line of customer app code (third-party libraries, payment SDKs, OAuth flows). Option 1 — worked example in the POC.
standardwebhooks + transitives fast-sha256, @stablelib/base64 @clerk/backend 1 (tasn) + 1 (dchest) for both transitives Webhook signature verification primitive. Compromise replaces HMAC-SHA256 / base64 logic; could silently make verifyWebhook() always-return-success. Option 1 or Option 3 — REPLACE via crypto.subtle HMAC + native base64 collapses three single-maintainer accounts (tasn + dchest×2) into platform primitives the package already uses elsewhere (e.g. JWT verification).
server-only (no public source repo; npm repository field is null. Owner: sebmarkbage) @clerk/nextjs (direct dep, packages/nextjs/package.json) 1 (sebmarkbage) Server-vs-client boundary marker. 611 bytes. Compromise is a privileged-primitive substitution affecting every Next.js server-component path. Option 1 — trivial source, high-leverage compromise vector. The lack of a public source repo is itself a supply-chain signal — auditors can't diff a new version against the previous source.
std-env @clerk/shared postinstall 1 (pi0) isCI check during the postinstall script that runs in customers' installs. Bounded to install-time, but pi0 controls ~50 reachable packages — high upstream-trust-set exposure for a one-liner check. Option 3 (REPLACE) — narrowed to the subset Clerk actually needs for telemetry suppression. std-env's isCI recognizes ~20 provider-specific env vars; a Clerk-side replacement should either pick the subset that matches Clerk's telemetry threat model (probably process.env.CI plus a handful of named CI providers) or accept the narrowing. Either is fine; the choice should be explicit.
dequal @clerk/shared, @clerk/clerk-js, @clerk/ui 1 (lukeed) Deep-equality used in React state comparison (useDeepEqualMemo) and localization parsing. Runs on every render that goes through these paths. Option 2 (bundle) — semantic surface is small enough that upgrade-review attention isn't load-bearing; bundling closes the customer-side walk with minimal ceremony.

The audit surfaced additional candidates with weaker customer-side exposure stories — bounded blast radius, CLI-only consumers, deps where REPLACE is genuinely cheap — that are also worth considering but didn't make this short list. Available on request.

POC

base-64 in @clerk/expo — branch aaronb/vendor-base-64-poc. Implements Option 1 end-to-end, including a pnpm pack + clean-fixture install that empirically verifies base-64 no longer lands in customer node_modules after the change. The branch's README documents the verification steps run (byte-equivalence vs. the npm tarball, parity test against upstream, build through tsup, the published-tarball smoke test mentioned above, ESLint regression guard) and the case-specific reasoning for choosing Option 1 over the alternatives. The same verification shape is reusable for any future Option 1 decision.

References

  • Existing internal precedent (Go-side lift-and-shift): clerk_go/api/scimgateway/imported/
  • Existing internal precedent (JS-side vendor for sync crypto): packages/nextjs/src/vendor/
  • Hermes #1379 — context for the base-64 polyfill that the POC vendors.

Aaron Burrow and others added 3 commits May 17, 2026 16:13
Adds packages/expo/src/vendor/base-64/ — verbatim copy of the base-64@1.0.0
npm tarball plus a Clerk-side shim, README, and parity test. No consumer
code is wired yet; that's the next commit so reviewers can see the vendor
in isolation.

Layout:
  packages/expo/src/vendor/base-64/
  ├── README.md                  Clerk-side: rationale + customer-side attack chains
  ├── index.ts                   Clerk-side shim with typed re-exports
  ├── upstream/                  ← byte-for-byte copy of base-64@1.0.0 npm tarball
  │   ├── base64.js              (UMD; 164 lines; exports {encode, decode, version})
  │   ├── LICENSE-MIT.txt
  │   ├── package.json           (upstream's; inert fields documented in README)
  │   └── README.md
  └── __tests__/parity.spec.ts   RFC 4648 fixtures + extras + 512 deterministic fuzz

Also adds `!packages/*/src/vendor/**` to the root .gitignore so the
`dist`/`packages/*/dist/**` patterns above it don't silently exclude
vendored source under src/vendor/ (caught during the dequal POC on the
sibling branch — without this, future vendors with a `dist/` subdir would
be invisible to git and absent from the published tarball).

WHY THIS PACKAGE:

base-64 is a single-maintainer (mathias) npm package on which @clerk/expo
depends for the userland atob/btoa implementation it polyfills onto
global. When @clerk/expo is installed by a customer, the published tarball
declares base-64 as a runtime external; the customer's package manager
resolves "^1.0.0" against the npm registry and fetches base-64 fresh.

Clerk's own pnpm-lock.yaml is not in the published tarball and plays no
part in the customer's install. Two attack chains follow:

  Chain 1 — Publisher account compromise: mathias's npm account is
    compromised, attacker publishes base-64@1.0.1 with malicious code,
    customer's caret range resolves to 1.0.1 on next install. The
    polyfill assigns the compromised encode/decode to global.btoa /
    global.atob — every subsequent btoa()/atob() call anywhere in the
    customer's app, including third-party libraries, runs through the
    compromised code silently.

  Chain 2 — Registry-level same-version substitution: registry serves
    substituted bytes for an existing base-64@1.0.0 (registry compromise,
    malicious unpublish-then-republish within npm's 72-hour window, npm
    internal compromise). Customer's first install fetches the substituted
    bytes, computes their hash, records it as the trusted reference. No
    prior hash to compare against; future installs "verify" against the
    poisoned hash.

Exact version pinning ("base-64": "1.0.0") closes Chain 1 but not Chain 2.
Vendoring closes both — the customer's resolver never fetches base-64 from
the npm registry because the bytes ship inside the @clerk/expo npm tarball.

See packages/expo/src/vendor/base-64/README.md for the full rationale and
the bugprimer Sessions/S161/PROPOSAL.md for the broader proposal context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ncies

- packages/expo/src/polyfills/base64Polyfill.ts: import { decode, encode }
  from the vendored copy at ../vendor/base-64 (resolves to the shim at
  src/vendor/base-64/index.ts) instead of the npm base-64 package.
- packages/expo/package.json: remove "base-64": "^1.0.0" from
  dependencies; move it to devDependencies so the parity test at
  src/vendor/base-64/__tests__/parity.spec.ts can keep comparing the
  vendored output against the upstream npm package.
- pnpm-lock.yaml: regenerated.

After this commit, a customer who runs `pnpm install @clerk/expo` (or
npm/yarn/expo install equivalent) gets the vendored base-64 source as
part of the @clerk/expo tarball. Their resolver does not walk to
base-64 on npm. Both attack chains described in the previous commit
are closed for that customer.

Verification:
  Layer 1 (byte-equivalence): vendor/upstream/ == npm pack base-64@1.0.0
  Layer 3 (parity): 29 tests pass (RFC 4648 + extra + 512 fuzz)
  Layer 5 (build):  @clerk/expo builds clean via turbo;
                    dist/vendor/base-64/upstream/base64.js IS in dist
                    (so it ships in the published tarball, not require'd)
  Source grep: no remaining `from 'base-64'` outside the parity test
               (which has eslint-disable-next-line annotations).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `base-64` to the eslint.config.mjs no-restricted-imports `paths:`
list with a message pointing at the vendored copy. The parity test in
packages/expo/src/vendor/base-64/__tests__/parity.spec.ts imports
base-64 intentionally with eslint-disable-next-line annotations.

Verified: the rule fires on a deliberate `import { encode } from 'base-64'`
in a non-test file with the expected error message; passes for the
parity test (which has eslint-disable on the relevant lines).

Verification Layer 7 (regression guard) from VENDORING.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 17, 2026

⚠️ No Changeset found

Latest commit: 0dbae44

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel
Copy link
Copy Markdown

vercel Bot commented May 17, 2026

Someone is attempting to deploy a commit to the Clerk Production Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions github-actions Bot added the expo label May 17, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 17, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This PR vendors the base-64 package at version 1.0.0 inside @clerk/expo to mitigate dependency supply-chain risks. The change includes the complete upstream base-64 implementation copied byte-for-byte under packages/expo/src/vendor/base-64/upstream/, a TypeScript wrapper that re-exports the upstream encode and decode functions, comprehensive parity tests verifying behavior equivalence with the upstream npm package, and updates to the base64 polyfill to use the vendored module instead of the external dependency. Configuration changes include moving react-native-url-polyfill and tslib to devDependencies, adding an ESLint rule preventing direct imports of the external base-64 package, and updating .gitignore to ensure the vendor directory is not ignored.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive The title 'Aaronb/vendor base 64 poc' is cryptic and uses a branch name convention rather than a clear summary of the main change. Revise the title to be more descriptive and user-friendly, such as 'Vendor base-64 dependency in @clerk/expo to mitigate supply-chain attacks' or similar.
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed The description provides extensive, well-structured documentation of supply-chain attack chains, mitigation options, and implementation details directly relevant to the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@eslint.config.mjs`:
- Around line 354-358: The rule object with name 'base-64' currently blocks all
imports of upstream base-64 and will lint-break parity tests that intentionally
import the package; update the ESLint configuration so this restriction applies
only to non-test Expo source (e.g., scope the rule to packages/expo/src/** or
add an overrides entry that exempts test files like **/*.test.* and
**/__tests__/**), or add a negative pattern to the rule's target to exclude test
paths; locate the rule by the object with name 'base-64' and apply the
file-globbing change or add an overrides block to allow imports in test files.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 9f925187-b2a1-4351-8714-57c93b872fab

📥 Commits

Reviewing files that changed from the base of the PR and between 097ad4a and 0dbae44.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (11)
  • .gitignore
  • eslint.config.mjs
  • packages/expo/package.json
  • packages/expo/src/polyfills/base64Polyfill.ts
  • packages/expo/src/vendor/base-64/README.md
  • packages/expo/src/vendor/base-64/__tests__/parity.spec.ts
  • packages/expo/src/vendor/base-64/index.ts
  • packages/expo/src/vendor/base-64/upstream/LICENSE-MIT.txt
  • packages/expo/src/vendor/base-64/upstream/README.md
  • packages/expo/src/vendor/base-64/upstream/base64.js
  • packages/expo/src/vendor/base-64/upstream/package.json

Comment thread eslint.config.mjs
Comment on lines +354 to +358
{
name: 'base-64',
message:
"base-64 is vendored at packages/expo/src/vendor/base-64. Import { encode, decode } from '../vendor/base-64' instead. See packages/expo/src/vendor/base-64/README.md.",
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Global base-64 restriction conflicts with parity tests and can break lint.

This rule is global, but the PR includes parity tests that intentionally import the upstream base-64 package for behavioral comparison. As written, those tests will be lint-blocked unless excluded.

Scope this restriction to non-test Expo source files (or add a test-file override exception).

Suggested fix (scope restriction away from test files)
   {
     name: 'repo/global',
@@
       'no-restricted-imports': [
         'error',
         {
           paths: [
             {
               message: "Please always import from '`@clerk/shared/`<module>' instead of '`@clerk/shared`'.",
               name: '`@clerk/shared`',
             },
-            {
-              name: 'base-64',
-              message:
-                "base-64 is vendored at packages/expo/src/vendor/base-64. Import { encode, decode } from '../vendor/base-64' instead. See packages/expo/src/vendor/base-64/README.md.",
-            },
           ],
@@
       ],
     },
   },
+  {
+    name: 'packages/expo base-64 restriction',
+    files: ['packages/expo/src/**/*.{ts,tsx,js,jsx}'],
+    ignores: ['packages/expo/src/**/__tests__/**', 'packages/expo/src/**/*.test.{ts,tsx,js,jsx}'],
+    rules: {
+      'no-restricted-imports': [
+        'error',
+        {
+          paths: [
+            {
+              name: 'base-64',
+              message:
+                "base-64 is vendored at packages/expo/src/vendor/base-64. Import { encode, decode } from '../vendor/base-64' instead. See packages/expo/src/vendor/base-64/README.md.",
+            },
+          ],
+        },
+      ],
+    },
+  },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@eslint.config.mjs` around lines 354 - 358, The rule object with name
'base-64' currently blocks all imports of upstream base-64 and will lint-break
parity tests that intentionally import the package; update the ESLint
configuration so this restriction applies only to non-test Expo source (e.g.,
scope the rule to packages/expo/src/** or add an overrides entry that exempts
test files like **/*.test.* and **/__tests__/**), or add a negative pattern to
the rule's target to exclude test paths; locate the rule by the object with name
'base-64' and apply the file-globbing change or add an overrides block to allow
imports in test files.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant