Skip to content

fix(isBase32): reject impossible padding lengths - #2852

Open
maximilliangrand wants to merge 1 commit into
validatorjs:masterfrom
maximilliangrand:fix/isbase32-padding-length
Open

fix(isBase32): reject impossible padding lengths#2852
maximilliangrand wants to merge 1 commit into
validatorjs:masterfrom
maximilliangrand:fix/isbase32-padding-length

Conversation

@maximilliangrand

Copy link
Copy Markdown

isBase32 accepts strings that no RFC 4648 encoder can produce and no RFC 4648 decoder will accept.

The contract

RFC 4648 section 6 encodes each 40-bit (5-octet) group as 8 characters. When the final group is short, it is zero-extended and then padded with = to a full 8 characters:

final group encoded chars = characters
5 octets 8 0
4 octets 7 1
3 octets 5 3
2 octets 4 4
1 octet 2 6

So the number of padding characters in a well-formed base32 string is always 0, 1, 3, 4 or 6 — never 2, 5 or 7. (This is why every valid fixture already in the suite has a padding run of 0, 1, 3, 4 or 6 — JBSWY3DP, JBSWY3A=, JBSWY===, JBSQ====, ZG======, JBSWY3DPEA======, K5SWYY3PNVSSA5DPEBXG6ZA= and K5SWYY3PNVSSA5DPEBXG6=== cover exactly those five lengths and no others.)

isBase32 only enforces two of the three rules: the alphabet and the "length is a multiple of 8" rule. The padding-length rule is missing, so padding runs of 2, 5 and 7 characters are accepted even though no encoder can emit them — as is any longer run, provided the total length stays a multiple of 8.

Reproduction (published release, validator@13.15.35)

const validator = require('validator'); // 13.15.35

validator.isBase32('JBSWY3==');  // 2 padding characters
validator.isBase32('JBS=====');  // 5 padding characters
validator.isBase32('J=======');  // 7 padding characters

Observed output:

true
true
true

An independent decoder rejects all three (Python 3 stdlib, same RFC):

b32decode('JBSWY3==' ) -> binascii.Error: Incorrect padding
b32decode('JBS=====' ) -> binascii.Error: Incorrect padding
b32decode('J=======' ) -> binascii.Error: Incorrect padding
b32decode('ZG======' ) -> b'\xc9'
b32decode('JBSQ====' ) -> b'He'
b32decode('JBSWY===' ) -> b'Hel'
b32decode('JBSWY3A=' ) -> b'Hell'
b32decode('JBSWY3DP') -> b'Hello'

The same gap also lets an unbounded padding run through, as long as the total length stays a multiple of 8:

validator.isBase32('A' + '='.repeat(1048575)); // -> true

Root cause

src/lib/isBase32.js:19

return str.length % 8 === 0 && base32.test(str);

with base32 = /^[A-Z2-7]+=*$/ (line 4). =* places no constraint on how many padding characters there are, and the % 8 check only constrains the total length, so JBSWY3== (6 data + 2 pad) and J======= (1 data + 7 pad) satisfy both conditions.

For comparison, isBase64 already enforces the analogous rule: its patterns end in ={0,2}, which is exactly the padding set RFC 4648 section 4 permits for base64 ({0, 1, 2}) — isBase64('a===') is correctly false. isBase32 uses an unbounded =* instead, so the base32 path is missing a constraint its base64 sibling already applies.

Fix

Add the missing rule as an explicit check rather than folding it into the regex. isBase64 had a regex-driven stack overflow on large inputs (#2573, fixed in #2574 by separating the length check from the pattern), so this deliberately avoids introducing a nested quantifier such as (?:[A-Z2-7]{8})*…; indexOf is linear and allocation-free, and the character-class regex is unchanged.

const validPaddingLengths = [0, 1, 3, 4, 6];

function hasValidPadding(str) {
  const paddingStart = str.indexOf('=');

  return includes(validPaddingLengths, paddingStart === -1 ? 0 : str.length - paddingStart);
}

hasValidPadding runs last, after base32.test(str) has already guaranteed that every = belongs to a single trailing run.

Evidence

Differential check against an independent RFC 4648 §6 encoder written for this report:

padding lengths an RFC 4648 encoder can emit: 0, 1, 3, 4, 6
padding lengths validator.isBase32 accepts (before), at total length 8: 0, 1, 2, 3, 4, 5, 6, 7
  (in general any run up to length - 1: 0-15 at total length 16, 0-23 at total length 24)
padding lengths validator.isBase32 accepts (after) : 0, 1, 3, 4, 6

Fail-then-pass, against a rebuilt baseline (src/lib/isBase32.js restored from origin/master, npm run build re-run, generated lib/, es/ and validator.js confirmed not to contain the new symbol):

  Validators
    1) should reject base32 strings with an impossible number of padding characters

  0 passing (41ms)
  1 failing

  1) Validators
       should reject base32 strings with an impossible number of padding characters:
     Error: validator.isBase32("JBSWY3==") passed but should have failed

With the fix (lib/isBase32.js, es/lib/isBase32.js and validator.js all rebuilt and containing hasValidPadding):

  Validators
    ✓ should reject base32 strings with an impossible number of padding characters

  1 passing (42ms)

Full npm test (builds all distributions, lints, then runs the suite): 324 passing, 0 failing (323 before this change). Statements/lines/functions coverage stays at 100%; branch coverage unchanged at 96.59%.

Regression surface

Measured in both directions.

  • Newly rejected. 20 000 encoder outputs (input lengths 1–200 octets) were re-validated: 0 are rejected by the new code. Enumerating every shape A{k}={L-k} for total lengths 8, 16 and 24 gives 33 strings whose result changes — all of them true -> false, and every one has a padding run of length ∉ {0, 1, 3, 4, 6}.
  • Newly accepted. None: the change only adds a conjunct.
  • This is a tightening, so strings that were previously accepted can now return false. None of them is producible by a conforming encoder or decodable by a conforming decoder, so no valid base32 input is affected.
  • The crockford: true path returns before the new check and is untouched. isBase32('') remains false, matching the existing fixture.

Security / performance

No regex was added or modified, so there is no new backtracking surface. The added work is one String.prototype.indexOf plus a five-element array scan. Timed over adversarial inputs (20 iterations each, before vs. after):

input before after
1 MB, no padding 0.381 ms 0.415 ms
1 MB, 6 padding characters 0.388 ms 0.403 ms
'A' + 1 048 575 '=' 0.299 ms 0.310 ms (and now correctly false)
1 MB ending in an invalid character 2.289 ms 2.281 ms
1 MB of alternating '=A' 0.001 ms 0.001 ms
8 MB, no padding 3.077 ms 3.225 ms

What I did not verify / deliberately left alone

  • Canonicality of the final quantum's unused bits (RFC 4648 §3.5) is still not checked — e.g. ZH====== has non-zero trailing bits. That is a separate rule from padding length and a separate behaviour change, so it is out of scope here.
  • Lower-case input remains rejected, and isBase32('') remains false — both are covered by existing fixtures and unchanged.
  • Nothing in the crockford branch was touched.
  • CI results for Node 8/10/12 are not something I could run locally. The only runtime APIs added are String.prototype.indexOf and Array.prototype.some (via the existing util/includesArray helper), both ES5, and the transpiled lib/isBase32.js contains no ES6+ syntax.

Note on #2698

Open PR #2698 splits test/validators.test.js into per-function files. If it lands first, the new it() block here needs to move to test/validators/isBase32.test.js — happy to rebase whichever way round is convenient.

References

Checklist

  • PR contains only changes related; no stray files, etc.
  • README updated (where applicable) — not applicable: no public option, signature or documented return contract changes; the README already describes isBase32 as "check if the string is base32 encoded".
  • Tests written (where applicable)
  • References provided in PR (where applicable)

RFC 4648 section 6 encodes each 40-bit group as 8 characters. A final
group of 1, 2, 3 or 4 octets encodes to 2, 4, 5 or 7 characters and is
padded to 8, so a conforming encoder emits 0, 1, 3, 4 or 6 padding
characters - never 2, 5 or 7.

isBase32 only checked that the length is a multiple of 8 and that the
string matches /^[A-Z2-7]+=*$/, so it accepted shapes no encoder can
produce and no decoder accepts, e.g. 'JBSWY3==' (2 padding characters),
'JBS=====' (5) and 'J=======' (7). It also accepted an arbitrarily long
padding run such as 'A' followed by 1048575 '=' characters.

Add an explicit padding-length check. Every string an RFC 4648 encoder
can produce is still accepted; the change only removes shapes that are
unreachable under the standard.
@codecov

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (cdb7daf) to head (f2209a8).

Additional details and impacted files
@@            Coverage Diff            @@
##            master     #2852   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          114       114           
  Lines         2599      2604    +5     
  Branches       658       659    +1     
=========================================
+ Hits          2599      2604    +5     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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