Skip to content

fix(base64_decode): throw on invalid input in every build - #971

Open
josefguenther wants to merge 3 commits into
harttle:masterfrom
josefguenther:fix/base64-decode-invalid-input
Open

josefguenther wants to merge 3 commits into
harttle:masterfrom
josefguenther:fix/base64-decode-invalid-input

Conversation

@josefguenther

@josefguenther josefguenther commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor
{{ "hello" | base64_decode }}

On master the node build renders garbage bytes (��e), and the browser builds throw atob's own error, whose message depends on the input and the runtime. Shopify/Liquid raises invalid base64 provided to base64_decode. This throws that error from a check both builds share, before decoding. {{ v | base64_decode }}:

v master (node) master (browser) this PR (both)
"YWJj" abc abc abc
"YWJjZA" (unpadded) abcd abcd abcd
"YW Jj" (whitespace) abc abc abc
"hello" ��e RenderError: The string to be decoded is not correctly encoded. RenderError: invalid base64 provided to base64_decode
"YWJj!!" abc RenderError: Invalid character RenderError: invalid base64 provided to base64_decode
"-_-_" (URL-safe) ��� RenderError: Invalid character RenderError: invalid base64 provided to base64_decode
5 (empty) RenderError: The string to be decoded is not correctly encoded. RenderError: invalid base64 provided to base64_decode

The check accepts exactly what atob accepts, so unpadded and whitespace-separated input still decodes in both builds. Shopify rejects those two as well, but rejecting them here would break input that works today.

The tests cover the node build in test/integration/filters/base64.spec.ts and the browser bundle in test/e2e/browser.spec.ts. npm run build, npm test (1676 tests) and npm run lint pass on Node 24.11.1.

Fixes #965

Written with AI assistance (Claude Code); I have reviewed the change.

The two builds disagreed on input that isn't valid Base64. The node
build decodes with Buffer, which skips characters outside the alphabet,
so ordinary text such as "hello" or "[object Object]" decoded to
garbage bytes. The browser builds decode with atob, which throws, so
the same input failed the whole render.

base64_decode now checks the input before decoding, in the filter
shared by both builds: exactly what atob accepts (the standard
alphabet, optional padding and whitespace) decodes as before, and
anything else renders an empty string. Valid input is decoded the same
way by both builds.

Fixes harttle#965

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@harttle

harttle commented Oct 1, 2026

Copy link
Copy Markdown
Owner

Sorry for the late response. RenderError is expected for invalid input, as it is consistent with Shopify/liquid, FYI: this is what I get from the Ruby version:

"b25lIHR3byB0aHJlZQ=="    ok              "one two three"
"YWJj"                    ok              "abc"
"YWJjZA"                  unexpected_error  Liquid error: invalid base64 provided to base64_decode
"YW Jj"                   error           Liquid error: invalid base64 provided to base64_decode
"hello"                   error           Liquid error: invalid base64 provided to base64_decode
"invalidbase64"           error           Liquid error: invalid base64 provided to base64_decode
"YWJj!!"                  error           Liquid error: invalid base64 provided to base64_decode
"-_-_"                    error           Liquid error: invalid base64 provided to base64_decode
"!!!"                     error           Liquid error: invalid base64 provided to base64_decode
""                        ok              ""
5                         error           Liquid error: invalid base64 provided to base64_decode
{:a=>1}                   error           Liquid error: invalid base64 provided to base64_decode

What makes LiquidJS more consistent is to throw same errors for both browser and Node.js. But we also want to avoid too complicated or slow implementation as it's only an edge case. Otherwise I'd rather document it as a "difference from Shopify/liquid".

@harttle harttle added the bug label Oct 1, 2026
Shopify/Liquid's default render writes an error message in place of
the filter's output and renders the rest of the page; only render!
raises. "Raises an error" read as failing the render.

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

Copy link
Copy Markdown
Contributor Author

Thanks for running these through Ruby. Those "Liquid error: …" strings are what makes me think throwing doesn't match Shopify here. Shopify's default render writes that message in place of the failing tag, records the error, and renders the rest of the page. Only render! lets the error escape. So in Shopify, raising in these filters reports the bad value, and the page still renders.

LiquidJS fails the whole render on any error (catchAllErrors collects them but still throws at the end), so throwing here would match render! rather than render: one bad base64 value or %ff in a query string, which comes from the data and not the template, would take the whole page down. LiquidJS already keeps rendering in the same situation elsewhere: Shopify reports an error for divided_by: 0, and LiquidJS renders Infinity.

So I'd suggest keeping the render going, as Shopify does: an empty string here, U+FFFD for invalid UTF-8 in #970. The fully faithful version would be Shopify's model, where a filter error is kept to its own tag and reported, but that changes how every render error behaves, so it seemed beyond these PRs and maybe a better fit for v11. Happy to open an issue for that if it interests you.

@josefguenther

Copy link
Copy Markdown
Contributor Author

By the way, I tested and Shopify reports an error for bad data in about a dozen filters (slice/truncate with a non-integer length, sort on mixed types, map on a non-array, …) and keeps rendering, and LiquidJS renders something for all of them; these two (#970/#971) would be the only ones that fail the render.

@harttle

harttle commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

Thanks for the clarification.

  • continue rendering should be a cross-filter feature. We can have a rethrowErrors switch for LiquidJS, while as for the filter itself, it should throw to allow error handling in higher level, like render and render! in Ruby.
  • the divided_by: 0 case: I think we can fix it to align, as it won't require much complexity.

I see there can be many differences still. The principle would be: if it doesn't require a lot of complexity, we should align with Shopify; if there'd be a breaking change, we do it in v11 by adding the issue there; if it's not convenient for JS to implement, we document in the differences section.

Invalid input rendered an empty string. It now throws "invalid base64
provided to base64_decode", the message Shopify/Liquid uses, from the
check both builds share, so the node and browser builds fail the same
way on the same input. Padding stays optional and whitespace ignored,
as both builds accept today.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@josefguenther josefguenther changed the title fix(base64_decode): render invalid input as an empty string fix(base64_decode): throw on invalid input in every build Oct 7, 2026
@josefguenther

Copy link
Copy Markdown
Contributor Author

Reworked: both builds now throw Shopify's invalid base64 provided to base64_decode, from one shared regex check. Unpadded and whitespace input still decode, since both builds accept them today. Added a browser-bundle test in test/e2e/browser.spec.ts and updated the title and description. I'll open a separate issue for rethrowErrors.

This branch has not been deployed

No deployments
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.

base64_decode throws in browser builds on input the node build accepts

2 participants