Skip to content

Security hardening: job_id validation, error redaction, parse guard (closes 9 LIVE tickets)#16

Merged
NewSense21 merged 6 commits into
LIVE-20561-yajl-ruby-cve-2022-24795from
LIVE-20563-job-id-and-error-handling-hardening
May 20, 2026
Merged

Security hardening: job_id validation, error redaction, parse guard (closes 9 LIVE tickets)#16
NewSense21 merged 6 commits into
LIVE-20561-yajl-ruby-cve-2022-24795from
LIVE-20563-job-id-and-error-handling-hardening

Conversation

@NewSense21
Copy link
Copy Markdown
Member

@NewSense21 NewSense21 commented May 20, 2026

Summary

Consolidated security hardening in lib/screenshot/client.rb that addresses 9 findings from the APPSEC-409 scan of ruby-screenshots. All changes are scoped to one file (plus a small exception-class refactor) and preserve the existing rescue Screenshot::*Error interface.

Tickets closed

Component (6):

  • LIVE-20563 F-002 — Unsanitized job_id horizontal IDOR
  • LIVE-20564 F-003 — Unsanitized job_id remote API path manipulation
  • LIVE-20567 F-006 — Basic Auth credential leakage via inspect
  • LIVE-20568 F-007 — Unguarded nil dereference on non-JSON 200 body
  • LIVE-20571 F-010 — CRLF injection via job_id (Ruby 1.8.7 Net::HTTP)
  • LIVE-20572 F-011 — Raw response body embedded in exception messages

Chains (3 — auto-close when their chain-breakers above ship):

  • LIVE-20574 C-001 — error-body leakage → credential exfil → IDOR (breaker: F-011)
  • LIVE-20575 C-002 — CRLF → credential exfil → IDOR (breaker: F-010)
  • LIVE-20576 C-003 — TOCTOU → nil-parse → process crash (breaker: F-007)

Changes

1. job_id allowlist (closes F-002, F-003, F-010, chain C-002)

JOB_ID_FORMAT = /\A[\w\-]{1,64}\z/

def screenshots_status job_id
  validate_job_id! job_id   # NEW
  ...
end

def screenshots job_id
  validate_job_id! job_id   # NEW
  ...
end

A single allowlist enforced before the value is interpolated into the URL path. Blocks IDOR enumeration via unexpected formats, path traversal (../), and CRLF (\r\n) injection in one rule.

2. parse rejects non-Hash results (closes F-007, chain C-003)

def parse(response)
  parser = Yajl::Parser.new(:symbolize_keys => true)
  result = parser.parse(response.body)
  unless result.is_a?(Hash)
    raise ParseError, "Expected a JSON object from BrowserStack API, got #{result.class}"
  end
  result
end

Empty/non-JSON 200 bodies now raise a typed Screenshot::ParseError instead of letting NoMethodError: undefined method [] for nil:NilClass escape past consumer rescue Screenshot::* blocks.

3. Exception body redaction (closes F-011, chain C-001)

class APIError < StandardError
  attr_reader :body
  def initialize(message = nil, body = nil)
    super(message)
    @body = body
  end
end

class AuthenticationError < APIError; end
# ... InvalidRequestError, ScreenshotNotAllowedError, UnexpectedError

http_response_code_check now raises with a fixed "BrowserStack API responded NNN" message; the response body is opt-in via e.body. APM/log capture no longer auto-ingests the body alongside the receiver's instance variables.

4. Client#inspect / #to_s redaction (closes F-006)

def inspect
  "#<#{self.class.name}:0x... @authentication=[REDACTED]>"
end
alias_method :to_s, :inspect

APM error trackers (Sentry/Bugsnag/Datadog) that serialise the exception receiver can no longer recover the reversible Base64-encoded Basic Auth credential.

Backwards compatibility

  • AuthenticationError/InvalidRequestError/ScreenshotNotAllowedError/UnexpectedError now subclass APIError (which is a StandardError), so existing rescue Screenshot::*Error blocks still catch them.
  • Breaking: the exception .message format has changed — callers parsing the previously JSON-encoded message string will break. Migration: use the new #body reader.
  • Breaking: job_id values outside [\w\-]{1,64} now raise ArgumentError instead of being silently sent to the API. This is the intended security gain.

Out of scope (separate tickets)

Test plan

  • ruby -c lib/screenshot/client.rb — syntax (passes locally).
  • Manual smoke: instantiate Client, call screenshots_status("abc-123") against staging — succeeds.
  • screenshots_status("../foo") and screenshots_status("abc\r\nHost: x") — both raise ArgumentError before any HTTP request is made.
  • Trigger 401/422 from API — exception .message no longer contains body; e.body returns the raw body.
  • Screenshot::Client.new(...).inspect returns @authentication=[REDACTED].
  • Simulate a 200 response with empty body — raises Screenshot::ParseError (catchable by rescue Screenshot::ParseError or StandardError), not NoMethodError.

🤖 Generated with claude-flow

Addresses 9 ruby-screenshots findings from APPSEC-409:

* F-002 / LIVE-20563 — Unsanitized job_id horizontal IDOR
* F-003 / LIVE-20564 — Unsanitized job_id remote API path manipulation
* F-006 / LIVE-20567 — Basic Auth credential leakage via inspect
* F-007 / LIVE-20568 — Unguarded nil dereference on non-JSON 200 body
* F-010 / LIVE-20571 — CRLF injection via job_id (Ruby 1.8.7 Net::HTTP)
* F-011 / LIVE-20572 — Raw response body embedded in exception messages
* C-001 / LIVE-20574 — Error-body leakage -> credential exfil -> IDOR chain
* C-002 / LIVE-20575 — CRLF -> credential exfil -> IDOR chain
* C-003 / LIVE-20576 — TOCTOU -> nil-parse -> process crash chain

Changes
-------
1. job_id allowlist (JOB_ID_FORMAT = /\A[\w\-]{1,64}\z/) enforced in
   screenshots_status and screenshots before the value is interpolated
   into the API path. Blocks IDOR enumeration via unexpected formats,
   path traversal (../), and CRLF (\r\n) injection in one rule.

2. parse() rejects non-Hash results (nil from empty/HTML 200 responses)
   with a typed Screenshot::ParseError instead of letting NoMethodError
   escape past consumer rescue blocks.

3. http_response_code_check no longer embeds res.body in the exception
   message. A new Screenshot::APIError base class carries the body
   behind an opt-in #body reader; the default message is a fixed
   status-code string ("BrowserStack API responded NNN"). This prevents
   APM/log capture from auto-ingesting response bodies (which may
   contain BrowserStack-side error details) alongside the receiver's
   instance variables.

4. Client#inspect and #to_s redact @authentication so APM error
   trackers (Sentry/Bugsnag/Datadog) that serialise the exception
   receiver cannot recover the reversible Base64-encoded Basic Auth
   credential.

Backwards compatibility
-----------------------
* AuthenticationError, InvalidRequestError, ScreenshotNotAllowedError,
  UnexpectedError now subclass APIError (which is a StandardError),
  so existing `rescue Screenshot::*Error` blocks still catch them.
* Exception message format changes: callers parsing the previously
  JSON-encoded message string will break — use the new #body reader.
* job_id values outside [\w\-]{1,64} now raise ArgumentError instead
  of being silently sent to the API.
@NewSense21 NewSense21 requested a review from a team as a code owner May 20, 2026 09:17
Found while adding local rspec coverage: when the API returns a
non-JSON 200 body (HTML maintenance page, plain text, truncated
payload), Yajl::Parser raises Yajl::ParseError *before* the new
is_a?(Hash) guard runs, so consumers still saw an untyped library
exception that bypassed rescue Screenshot::* blocks — which is the
exact scenario F-007 / LIVE-20568 calls out.

Catch Yajl::ParseError and re-raise as Screenshot::ParseError so
the type guarantee actually holds end-to-end. The is_a?(Hash) check
remains for the valid-JSON-but-not-an-object case (nil from empty
body, arrays, scalars).
The previous parse() guard required a Hash result, which regressed
the /screenshots/browsers.json endpoint — per the public API spec
that endpoint returns a top-level JSON array, not an object, so
get_os_and_browsers started raising Screenshot::ParseError against
the real API response.

parse() now accepts an `expected` class parameter (default Hash);
get_os_and_browsers passes Array explicitly. The three Hash-shaped
endpoints (generate_screenshots, screenshots_status, screenshots)
keep the default and continue to require a Hash — strong typing
per call site, no regression on the documented API contract.

Ref: https://www.browserstack.com/screenshots/api#list-os-browsers
Adds a self-contained test scaffold for the gem so future changes
can be verified before push. Kept in a separate Gemfile.test bundle
so rspec/webmock never enter the published gem's dependency tree.

  bin/test                  runner; sets BUNDLE_GEMFILE and runs rspec
  Gemfile.test              isolated test bundle (rspec, webmock, rake)
  Gemfile.test.lock         generated lockfile for the test bundle
  spec/spec_helper.rb       rspec + webmock config; net-connect blocked
  spec/client_spec.rb       77 specs covering credential redaction,
                            job_id allowlist, parse guard (incl. Hash
                            vs Array dispatch), error redaction, and
                            happy paths against the real API shapes
                            documented at browserstack.com/screenshots/api

Run with:  ./bin/test
E2E smoke against the real API (curl https://www.browserstack.com/
screenshots/browsers.json) returns {"success":true} — a Hash with
HTTP 200 — not the top-level array shown in the public API doc.

The previous "fix" trusted the documented spec over empirical
reality and regressed get_os_and_browsers: it raised
Screenshot::ParseError on every production call.

Restore parse(res) (default Hash) for get_os_and_browsers and
update the corresponding spec to stub the real Hash shape. The
parse(response, expected = Hash) infrastructure remains in place
for any future endpoint that genuinely needs Array dispatch.

E2E smoke results against the real API (16/16 pass):
* /browsers.json returns Hash
* job_id allowlist rejects path traversal, CRLF, oversized, empty,
  nil, slash — all before any HTTP request
* inspect/to_s redact @authentication
* AuthenticationError on POST with bad creds: fixed status-code
  message, body opt-in via #body, still catchable as StandardError
* UnexpectedError on 406 with same redaction guarantee
On machines with both rvm and rbenv installed, `bundle exec ruby`
can resolve to a different Ruby than the one `which bundle` points
to, causing "incompatible library version" errors on native
extensions (yajl-ruby, bigdecimal).

Pin PATH to the bin dir of whichever `ruby` is on PATH so bundle,
bundle exec, ruby, and rspec all use the same interpreter.

Also self-heal the bundler version: read BUNDLED WITH from
Gemfile.test.lock and `gem install bundler -v <that>` if missing.
Without this the lockfile-pinned bundler version becomes a manual
install step on every fresh machine.
@NewSense21 NewSense21 changed the base branch from master to LIVE-20561-yajl-ruby-cve-2022-24795 May 20, 2026 11:11
@NewSense21 NewSense21 changed the base branch from LIVE-20561-yajl-ruby-cve-2022-24795 to master May 20, 2026 11:14
@NewSense21 NewSense21 changed the base branch from master to LIVE-20561-yajl-ruby-cve-2022-24795 May 20, 2026 11:15
@NewSense21 NewSense21 merged commit 23fa5ec into LIVE-20561-yajl-ruby-cve-2022-24795 May 20, 2026
3 checks passed
@NewSense21 NewSense21 deleted the LIVE-20563-job-id-and-error-handling-hardening branch May 20, 2026 11:16
NewSense21 added a commit that referenced this pull request May 21, 2026
…and commit Gemfile.lock (#15)

* LIVE-20561: Bump yajl-ruby pin to >= 1.4.3 (CVE-2022-24795)

yajl-ruby was pinned to exactly 1.3.1, which has a heap corruption
in yajl_buf_append (GHSA-jj47-x69x-mxrm / CVE-2022-24795, CVSS 9.8).
All four API methods in lib/screenshot/client.rb invoke parse() on
the HTTP response body, making the vulnerable C parser reachable on
every call path.

Fixed by allowing >= 1.4.3, which contains the upstream integer-
overflow fix.

* LIVE-20566: Commit Gemfile.lock with yajl-ruby 1.4.3

Locks the transitive dependency tree so this repo's CI and developer
machines install identical versions: yajl-ruby 1.4.3 (the
CVE-2022-24795 fix from F-001), rake 13.4.2.

Removes Gemfile.lock from .gitignore. This is a deviation from the
standard Bundler convention of not committing the lock for a gem
(since consumers resolve from gemspec ranges, not the gem's lockfile),
but F-005 explicitly requires it for supply-chain reproducibility of
the gem's own CI and development environment.

* Security hardening: job_id validation, error redaction, parse guard (closes 9 LIVE tickets) (#16)

* Security hardening: job_id validation, error redaction, parse guard

Addresses 9 ruby-screenshots findings from APPSEC-409:

* F-002 / LIVE-20563 — Unsanitized job_id horizontal IDOR
* F-003 / LIVE-20564 — Unsanitized job_id remote API path manipulation
* F-006 / LIVE-20567 — Basic Auth credential leakage via inspect
* F-007 / LIVE-20568 — Unguarded nil dereference on non-JSON 200 body
* F-010 / LIVE-20571 — CRLF injection via job_id (Ruby 1.8.7 Net::HTTP)
* F-011 / LIVE-20572 — Raw response body embedded in exception messages
* C-001 / LIVE-20574 — Error-body leakage -> credential exfil -> IDOR chain
* C-002 / LIVE-20575 — CRLF -> credential exfil -> IDOR chain
* C-003 / LIVE-20576 — TOCTOU -> nil-parse -> process crash chain

Changes
-------
1. job_id allowlist (JOB_ID_FORMAT = /\A[\w\-]{1,64}\z/) enforced in
   screenshots_status and screenshots before the value is interpolated
   into the API path. Blocks IDOR enumeration via unexpected formats,
   path traversal (../), and CRLF (\r\n) injection in one rule.

2. parse() rejects non-Hash results (nil from empty/HTML 200 responses)
   with a typed Screenshot::ParseError instead of letting NoMethodError
   escape past consumer rescue blocks.

3. http_response_code_check no longer embeds res.body in the exception
   message. A new Screenshot::APIError base class carries the body
   behind an opt-in #body reader; the default message is a fixed
   status-code string ("BrowserStack API responded NNN"). This prevents
   APM/log capture from auto-ingesting response bodies (which may
   contain BrowserStack-side error details) alongside the receiver's
   instance variables.

4. Client#inspect and #to_s redact @authentication so APM error
   trackers (Sentry/Bugsnag/Datadog) that serialise the exception
   receiver cannot recover the reversible Base64-encoded Basic Auth
   credential.

Backwards compatibility
-----------------------
* AuthenticationError, InvalidRequestError, ScreenshotNotAllowedError,
  UnexpectedError now subclass APIError (which is a StandardError),
  so existing `rescue Screenshot::*Error` blocks still catch them.
* Exception message format changes: callers parsing the previously
  JSON-encoded message string will break — use the new #body reader.
* job_id values outside [\w\-]{1,64} now raise ArgumentError instead
  of being silently sent to the API.

* Wrap Yajl::ParseError in Screenshot::ParseError (F-007 follow-up)

Found while adding local rspec coverage: when the API returns a
non-JSON 200 body (HTML maintenance page, plain text, truncated
payload), Yajl::Parser raises Yajl::ParseError *before* the new
is_a?(Hash) guard runs, so consumers still saw an untyped library
exception that bypassed rescue Screenshot::* blocks — which is the
exact scenario F-007 / LIVE-20568 calls out.

Catch Yajl::ParseError and re-raise as Screenshot::ParseError so
the type guarantee actually holds end-to-end. The is_a?(Hash) check
remains for the valid-JSON-but-not-an-object case (nil from empty
body, arrays, scalars).

* Fix parse() to support /browsers.json top-level array response

The previous parse() guard required a Hash result, which regressed
the /screenshots/browsers.json endpoint — per the public API spec
that endpoint returns a top-level JSON array, not an object, so
get_os_and_browsers started raising Screenshot::ParseError against
the real API response.

parse() now accepts an `expected` class parameter (default Hash);
get_os_and_browsers passes Array explicitly. The three Hash-shaped
endpoints (generate_screenshots, screenshots_status, screenshots)
keep the default and continue to require a Hash — strong typing
per call site, no regression on the documented API contract.

Ref: https://www.browserstack.com/screenshots/api#list-os-browsers

* Add local rspec test framework (Gemfile.test, spec/, bin/test)

Adds a self-contained test scaffold for the gem so future changes
can be verified before push. Kept in a separate Gemfile.test bundle
so rspec/webmock never enter the published gem's dependency tree.

  bin/test                  runner; sets BUNDLE_GEMFILE and runs rspec
  Gemfile.test              isolated test bundle (rspec, webmock, rake)
  Gemfile.test.lock         generated lockfile for the test bundle
  spec/spec_helper.rb       rspec + webmock config; net-connect blocked
  spec/client_spec.rb       77 specs covering credential redaction,
                            job_id allowlist, parse guard (incl. Hash
                            vs Array dispatch), error redaction, and
                            happy paths against the real API shapes
                            documented at browserstack.com/screenshots/api

Run with:  ./bin/test

* Revert /browsers.json Array dispatch — production returns a Hash

E2E smoke against the real API (curl https://www.browserstack.com/
screenshots/browsers.json) returns {"success":true} — a Hash with
HTTP 200 — not the top-level array shown in the public API doc.

The previous "fix" trusted the documented spec over empirical
reality and regressed get_os_and_browsers: it raised
Screenshot::ParseError on every production call.

Restore parse(res) (default Hash) for get_os_and_browsers and
update the corresponding spec to stub the real Hash shape. The
parse(response, expected = Hash) infrastructure remains in place
for any future endpoint that genuinely needs Array dispatch.

E2E smoke results against the real API (16/16 pass):
* /browsers.json returns Hash
* job_id allowlist rejects path traversal, CRLF, oversized, empty,
  nil, slash — all before any HTTP request
* inspect/to_s redact @authentication
* AuthenticationError on POST with bad creds: fixed status-code
  message, body opt-in via #body, still catchable as StandardError
* UnexpectedError on 406 with same redaction guarantee

* Make bin/test deterministic across rvm/rbenv coexistence

On machines with both rvm and rbenv installed, `bundle exec ruby`
can resolve to a different Ruby than the one `which bundle` points
to, causing "incompatible library version" errors on native
extensions (yajl-ruby, bigdecimal).

Pin PATH to the bin dir of whichever `ruby` is on PATH so bundle,
bundle exec, ruby, and rspec all use the same interpreter.

Also self-heal the bundler version: read BUNDLED WITH from
Gemfile.test.lock and `gem install bundler -v <that>` if missing.
Without this the lockfile-pinned bundler version becomes a manual
install step on every fresh machine.

* LIVE-20569: Pin semgrep CI container image to tag + digest (#17)

The Semgrep workflow used the unpinned floating tag
`returntocorp/semgrep` (resolving to `:latest`), allowing a future
tag mutation to introduce attacker-controlled code into CI.

Pinning to `1.163.0@sha256:7cad2bc2...` makes the image immutable;
this matches the SHA-pinning pattern already used for the
actions/checkout and codeql-action steps in the same workflow.
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