Skip to content

fix(crashlytics): harden parsing of server-provided settings - #16754

Open
paulb777 wants to merge 4 commits into
mainfrom
pb-harden-crashlytics-settings
Open

paulb777 wants to merge 4 commits into
mainfrom
pb-harden-crashlytics-settings

Conversation

@paulb777

@paulb777 paulb777 commented Oct 1, 2026

Copy link
Copy Markdown
Member

Context: #16728 — a malformed server payload parsed into an unexpected type crashed
production apps. This PR hardens the equivalent path in Crashlytics: settings
fetched from firebase-settings.crashlytics.com/spi/v2/.../settings, cached to
disk, and re-read at every launch in FIRCLSSettings.

This matters more than usual for Crashlytics: cached settings are read while the
crash reporter is being set up (FIRCLSContextBuildInitData), so a malformed
cached settings file would crash on every launch until the cache expired or the
app was reinstalled, and Crashlytics would likely not report that crash.

Hardened code paths (all in FIRCLSSettings.m)

Path Failure mode before this change
reloadFromCacheWithGoogleAppID: — settings file is valid JSON but not an object (e.g. [1,2,3]) -[__NSArrayI objectForKeyedSubscript:] unrecognized selector (NSInvalidArgumentException). The file is now discarded like other corrupt settings files.
app / session / features / fabric groups that aren't objects -[NSTaggedPointerString objectForKeyedSubscript:] unrecognized selector. Now treated as missing.
Individual values with the wrong type / null (cache_duration, collect_*, log_buffer_size, max_custom_*, on_demand_*) e.g. -[NSNull unsignedIntValue] unrecognized selector. Now falls back to the default. Bool switches still accept NSNumber or NSString, as before.
Negative numbers for uint32_t settings Wrapped to huge values via unsignedIntValue. For example, cache_duration: -1 produced ~136 years, so the settings TTL never expired. Now falls back to the default.
Numbers too large for uint32_t Truncated mod 2^32. For example, 4294967296 became 0. Now clamped to UINT32_MAX.
Non-finite / negative on_demand_upload_rate_per_minute, on_demand_backoff_base (-1e400 parses as -inf) Fed into the on-demand backoff math (pow, division). Now falls back to the default.
on_demand_backoff_step_duration_seconds of 0 (or a value that truncates/wraps to 0) Used as a divisor in FIRCLSOnDemandModel → NaN step/delay. Now falls back to the default (6).
Settings cache key file that isn't a JSON object (read with NSJSONReadingAllowFragments) -[__NSCFString objectForKeyedSubscript:] unrecognized selector. Now treated as a missing cache key, which invalidates the cache.
Cache key fields with the wrong type (google_app_id, created_at, build_instance_id, app_version) e.g. -[__NSCFNumber isEqualToString:] unrecognized selector. Now treated as a mismatch (Google App ID → cache deleted; others → cache marked expired).

Behavior for valid payloads does not change. Zero is still respected wherever
it means something (e.g. log_buffer_size: 0, on_demand_upload_rate_per_minute: 0,
cache_duration: 0).

Tests

Added to FIRCLSSettingsTests:

  • testSettingsNotADictionary
  • testSettingsGroupsWithWrongTypes
  • testSettingsValuesWithWrongTypes
  • testSettingsNegativeValues
  • testSettingsOutOfRangeValues
  • testSettingsZeroAndFractionalValues
  • testCacheKeyNotADictionary
  • testCacheKeyGoogleAppIDWrongType
  • testCacheKeyValuesWithWrongTypes

I ran each one on main without the fix. All 9 failed. Six of them threw
NSInvalidArgumentException (unrecognized selector): testSettingsNotADictionary,
testSettingsGroupsWithWrongTypes, testSettingsValuesWithWrongTypes,
testCacheKeyNotADictionary, testCacheKeyGoogleAppIDWrongType and
testCacheKeyValuesWithWrongTypes. The other 3 failed on assertions (wrapped or
truncated values, and a zero step duration). With the fix, all 9 pass.

Testing

xcodebuild -scheme FirebaseCrashlyticsUnit -destination 'platform=macOS' test
(SPM): full FirebaseCrashlyticsUnit suite passes.

Cached settings are read at launch while the crash reporter is being set
up, so malformed data in the settings file or its cache key could crash
on every launch. Validate the types of the settings dictionary, its
groups, its values and the cache key fields; fall back to defaults for
wrong-typed, negative or non-finite values; clamp values too large for
uint32_t; and reject a zero on-demand backoff step duration, which is
used as a divisor.

Refs #16728
@gemini-code-assist

Copy link
Copy Markdown
Contributor
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

@paulb777

paulb777 commented Oct 1, 2026

Copy link
Copy Markdown
Member Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request improves the robustness of Crashlytics settings parsing by introducing value validation helpers to safely handle malformed or unexpected types from the server or disk cache, preventing potential startup crashes. The review feedback suggests replacing isEqualToString: with isEqual: when comparing cached values (such as Google App ID, build instance ID, and app version) to avoid crashes if the compared properties are nil.

Comment thread Crashlytics/Crashlytics/Models/FIRCLSSettings.m Outdated
Comment thread Crashlytics/Crashlytics/Models/FIRCLSSettings.m Outdated
Comment thread Crashlytics/Crashlytics/Models/FIRCLSSettings.m Outdated
…ey checks

Addresses review feedback: compare cached Google App ID, build instance ID and app version with isEqual: instead of isEqualToString: so a nil current value is handled as a mismatch. Adds tests for nil build instance ID and nil Google App ID.
@paulb777

paulb777 commented Oct 1, 2026

Copy link
Copy Markdown
Member Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request improves the robustness of Crashlytics settings parsing by introducing validation helpers to handle malformed, unexpected, or out-of-range values from the server or cache, accompanied by comprehensive unit tests. The review feedback suggests checking for NSNull in FIRCLSSettingsIsValidNumber to prevent redundant debug logging for explicit JSON nulls, and using doubleValue instead of unsignedIntValue when reading the cached creation timestamp to preserve full precision.

Comment thread Crashlytics/Crashlytics/Models/FIRCLSSettings.m
Comment on lines +199 to +202
id cacheCreatedAtValue = cacheKey[CreatedAtKey];
NSTimeInterval cacheCreatedAt = [cacheCreatedAtValue isKindOfClass:[NSNumber class]]
? [cacheCreatedAtValue unsignedIntValue]
: 0;

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.

low

The CreatedAtKey is stored as a double representing the full NSTimeInterval timestamp in cacheSettingsWithGoogleAppID:currentTimestamp:. Retrieving it using unsignedIntValue truncates the fractional seconds. Using doubleValue preserves the full precision of the NSTimeInterval and is more idiomatic.

Suggested change
id cacheCreatedAtValue = cacheKey[CreatedAtKey];
NSTimeInterval cacheCreatedAt = [cacheCreatedAtValue isKindOfClass:[NSNumber class]]
? [cacheCreatedAtValue unsignedIntValue]
: 0;
id cacheCreatedAtValue = cacheKey[CreatedAtKey];
NSTimeInterval cacheCreatedAt = [cacheCreatedAtValue isKindOfClass:[NSNumber class]]
? [cacheCreatedAtValue doubleValue]
: 0;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in 084fe8f. created_at is now read via the existing FIRCLSSettingsDoubleValue helper. Missing, non-numeric, negative or non-finite values fall back to 0, which expires the cache. That also fixes a negative integer wrapping to a far-future timestamp under unsignedIntValue. Added tests for fractional seconds and for invalid values.

Addresses review feedback:
- Read created_at back as a double (it is written as one) instead of
  truncating it with unsignedIntValue. Missing, non-numeric, negative or
  non-finite values fall back to 0 so the TTL check expires the cache;
  previously a negative integer wrapped to a far-future timestamp.
- Treat explicit JSON nulls like missing values when validating numeric
  settings so they fall back to the default without a debug log.
@paulb777 paulb777 added this to the 13.1.0 - M188 milestone Oct 1, 2026
Comment thread Crashlytics/CHANGELOG.md Outdated

@ncooke3 ncooke3 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Non-blocking follow-ups: (1) consider falling back to the default instead of clamping out-of-range uint32_t values to UINT32_MAX, since a clamped cache_duration never expires and a clamped log_buffer_size effectively removes the log limit; (2) confirm the backend never sends on_demand_upload_rate_per_minute / on_demand_backoff_base as strings, since those are now ignored.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants