Skip to content

Localize version 0.30.0

Choose a tag to compare

@kipcole9 kipcole9 released this 12 May 06:11
· 78 commits to main since this release

[0.30.0] — May 12th, 2026

Security

  • Localize.LanguageTag.parse/1 no longer calls String.to_atom/1 on raw parser output, closing an atom-table-exhaustion DOS vector on untrusted locale inputs. Atomisation is now gated behind the CLDR validity sets after alias resolution, and unrecognised language/script/territory subtags return Localize.InvalidSubtagError.

  • Localize.Locale.to_locale_id/1 renamed to Localize.Locale.cldr_locale_id_from/1 and now returns {:ok, atom()} | {:error, Exception.t()}, gating atom creation behind Localize.validate_locale/1 and closing a second atom-table-exhaustion vector on locale inputs.

  • Localize.Currency.validate_currency/1, territory_currencies/1, current_currency_for_territory/1, and the binary-code branch of currencies_for_locale/3's filter no longer atomise input before checking validity. Unknown currency or territory binaries are rejected via Helpers.existing_atom/1 and never grow the atom table.

  • Localize.Script.display_name/2 and Localize.Unit.Formatter no longer atomise binary input before checking validity. Unknown script codes return Localize.UnknownScriptError without growing the atom table; the unit formatter's currency atomisation is gated as defence-in-depth behind the upstream Localize.Unit.validate_currency_codes/1 check.

  • MF2 :list function and unit parser no longer atomise user-controlled binaries. The :list function's binary style= fallthrough now sets a sentinel atom that surfaces as an InvalidValueError in Localize.List, and SI prefix names are resolved through a compile-time lookup map (Localize.Unit.Data.si_prefix_atom/1) rather than String.to_atom/1 at parse time.

  • Localize.Number.System (system_name_from/2, number_system_digits/1, to_system/2), Localize.Number.Symbol.number_symbols_for/2, and the datetime-formatter's time_preferences_for/1 no longer atomise user-supplied binary number-system or locale names before validation. Lookups go through Helpers.existing_atom/1 against pre-atomised CLDR data sets.

  • Closed additional Atom DOS vectors in Localize.Locale.LocaleDisplay.display_name/2 (now routes through cldr_locale_id_from/1), Localize.Territory.Subdivision.display_name/2, the -u-co- and -u-kr- extension parsers in Localize.Collation.Options, and the redundant String.to_atom(to_string(...)) round-trip in plural-rule fallback.

  • Closed three further atom-DOS sites called out by the security audit's findings 1.4 and 1.5: LocaleDisplay.U.find_exemplar_city/2 (-u-tz- IANA region/city splitter), LocaleDisplay.T.to_atom_safe/1 (-t- extension subtag normalisation), and Gettext.Interpolation.safe_to_atom/1 (missing-binding name reporting). All three previously fell through to String.to_atom/1 on a miss, which defeated the helper's name; they now return the original binary unchanged when no atom exists.

  • Locale cache files and downloaded ETFs are decoded with :erlang.binary_to_term(_, [:safe]). Closes a node-crash vector for any deployment with :locale_cache_dir set to a writable directory: a malicious or corrupted cache file can no longer resurrect arbitrary atoms, funs, or refs. Failed safe decodes surface as LocaleNotFoundInCacheError (or LocaleDownloadError for the download path) and the file is treated as stale.

  • Public parser entry points now reject oversized input before invoking the grammar, capping the parser's CPU exposure on hostile input. Defaults are 256 bytes for Localize.LanguageTag.parse/1 and Localize.Unit.Parser.parse/1, 64 KB for Localize.Message.Parser.parse/1, and 1 KB for Localize.Number.Parser.parse/2. Each cap is configurable via app env (:max_locale_id_bytes, :max_message_bytes, :max_unit_bytes, :max_number_bytes). Number.Parser.parse/2 additionally rejects Decimal results whose exponent magnitude exceeds :max_decimal_exponent (default ±100) so downstream multiplication or formatting cannot materialise huge mantissas.

  • Localize.FormatCache ETS table switched from :public to :protected; writes are routed through the cache GenServer. The size cap (:format_cache_max_entries, default 2 000) is now enforced synchronously on each insert rather than by a 10-second sweeper, replacing the previous biased-random eviction that could leave the cache oversized. New Localize.FormatCache.clear/0 and size/0 helpers added for tests and maintenance.

  • NIF (ICU bindings) hardened. All NIF entries except nif_plural_rule now run on the dirty CPU scheduler pool (ERL_NIF_DIRTY_JOB_CPU_BOUND); the collator pool is sized for schedulers + dirty_cpu_schedulers and reserve_coll refuses overflow rather than reading past the array end. The reorder-codes branch caps numCodes at 256 and checks enif_alloc before use; every std::stoll/std::stod/std::stoi is wrapped in try/catch so out-of-range C++ exceptions cannot unwind through the NIF boundary; the hand-rolled JSON arg parser guards each access after skip_ws; per-call input lengths are capped at the NIF boundary (MAX_MF2_BYTES = 64 KB, MAX_COLLATION_BYTES = 1 MB, MAX_NUMBER_STR_BYTES = 1 KB).

  • Localize.Unit.CustomRegistry.load_file/1 now refuses to evaluate the file in :prod (or any environment without a loaded Mix module — typical for releases) unless config :localize, :allow_runtime_unit_files, true is explicitly set. Outside :prod the function works as before. The flag exists so an unintended feature switch in production cannot accidentally surface arbitrary code execution via Code.eval_file/1.

  • :localize_locale_cache ETS table switched from :public to :protected, owned by Localize.Locale.Loader. Writes are routed through the owner via cast (so the hot validate path doesn't block and writes triggered from inside the owner's own handle_call cannot deadlock). Reads remain direct ETS lookups. Combined with the format cache fix above, both ETS caches are now :protected against multi-tenant or other-library interference.

  • Localize.Utils.Http.get/2 and get_with_headers/2 now reject responses larger than 50 MB by default (configurable via :max_http_body_bytes app env or per-call :max_body_bytes option). Without the cap a malicious or compromised CDN could feed a multi-gigabyte response and OOM the BEAM. Oversized responses log an error and return {:error, :response_too_large}. Additionally, when peer certificate verification has been disabled (via LOCALIZE_UNSAFE_HTTPS), a one-time Logger.warning is emitted so a misconfigured production deployment cannot silently downgrade TLS without leaving an audit trail.