Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 4 additions & 16 deletions sentry-rails/lib/sentry/rails/active_job.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# frozen_string_literal: true

require "set"
require "sentry/rails/error_reporter_context"
require "sentry/rails/structure_sanitizer"

module Sentry
module Rails
Expand Down Expand Up @@ -193,6 +195,7 @@ def capture_exception(job, e)
job_id: job.job_id,
provider_job_id: job.provider_job_id
},
contexts: ErrorReporterContext.contexts,
# Send synchronously: a worker process may exit before the async
# background worker flushes its queue, which would drop the event.
hint: { background: false }
Expand Down Expand Up @@ -248,22 +251,7 @@ def sentry_context(job)
end

def sentry_serialize_arguments(argument)
case argument
when Range
if (argument.begin || argument.end).is_a?(ActiveSupport::TimeWithZone)
argument.to_s
else
argument.map { |v| sentry_serialize_arguments(v) }
end
when Hash
argument.transform_values { |v| sentry_serialize_arguments(v) }
when Array, Enumerable
argument.map { |v| sentry_serialize_arguments(v) }
when ->(v) { v.respond_to?(:to_global_id) }
argument.to_global_id.to_s rescue argument
else
argument
end
Sentry::Rails::StructureSanitizer.sanitize(argument)
end

private
Expand Down
4 changes: 3 additions & 1 deletion sentry-rails/lib/sentry/rails/capture_exceptions.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require "sentry/rails/error_reporter_context"

module Sentry
module Rails
class CaptureExceptions < Sentry::Rack::CaptureExceptions
Expand Down Expand Up @@ -30,7 +32,7 @@ def capture_exception(exception, env)
return unless Sentry.initialized?
return if show_exceptions?(exception, env) && !Sentry.configuration.rails.report_rescued_exceptions

Sentry::Rails.capture_exception(exception).tap do |event|
Sentry::Rails.capture_exception(exception, contexts: ErrorReporterContext.contexts).tap do |event|
env[ERROR_EVENT_ID_KEY] = event.event_id if event
end
end
Expand Down
32 changes: 32 additions & 0 deletions sentry-rails/lib/sentry/rails/error_reporter_context.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# frozen_string_literal: true

require "sentry/rails/structure_sanitizer"

module Sentry
module Rails
module ErrorReporterContext
SUPPORTS_EXECUTION_CONTEXT = Gem::Version.new(::Rails.version) >= Gem::Version.new("7.0.0")

PRIMITIVE_CLASSES = [String, Numeric, Symbol, NilClass, TrueClass, FalseClass].freeze

class << self
if SUPPORTS_EXECUTION_CONTEXT
def contexts
execution_context = ::ActiveSupport::ExecutionContext.to_h
return {} if execution_context.empty?

sanitized = Sentry::Rails::StructureSanitizer.sanitize(execution_context) do |leaf|
PRIMITIVE_CLASSES.any? { |klass| leaf.is_a?(klass) } ? leaf : leaf.class.name
end
Comment thread
dingsdax marked this conversation as resolved.

{ "rails.error" => sanitized }
end
else
def contexts
{}
end
end
end
end
end
end
36 changes: 36 additions & 0 deletions sentry-rails/lib/sentry/rails/structure_sanitizer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# frozen_string_literal: true

module Sentry
module Rails
module StructureSanitizer
def self.sanitize(value, &leaf)

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.

why does this need to take a block

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@sl0thentr0py because ErrorReporterContext uses it for primitive-based filtering:

            sanitized = Sentry::Rails::StructureSanitizer.sanitize(execution_context) do |leaf|
              PRIMITIVE_CLASSES.any? { |klass| leaf.is_a?(klass) } ? leaf : leaf.class.name
            end

case value
when Range
sanitize_range(value, &leaf)
when Hash
value.transform_values { |v| sanitize(v, &leaf) }
when Array, Enumerable
value.map { |v| sanitize(v, &leaf) }
Comment thread
dingsdax marked this conversation as resolved.
when ->(v) { v.respond_to?(:to_global_id) }
begin
value.to_global_id.to_s
rescue StandardError
leaf ? leaf.call(value) : value
end
else
leaf ? leaf.call(value) : value
end
end

def self.sanitize_range(range, &leaf)
boundary = range.begin || range.end

if boundary.is_a?(::ActiveSupport::TimeWithZone)
range.to_s
else
range.map { |v| sanitize(v, &leaf) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Enumerable sanitization risks capture hangs

Medium Severity

StructureSanitizer expands any Enumerable or non-TimeWithZone Range with map before the leaf filter runs. ErrorReporterContext now runs that during exception capture, so values like an endless range or an ActiveRecord::Relation in Rails.error.set_context can hang or load large datasets and block reporting the original error.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 69d3b44. Configure here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: The sanitize_range method will raise a TypeError when processing a beginless range because it attempts to call .map on a range where begin is nil.
Severity: MEDIUM

Suggested Fix

Add a guard condition in sanitize_range to check if range.begin is nil. If it is, the range cannot be iterated. The method should handle this case gracefully, for example by not expanding the range and instead converting it to a string representation.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: sentry-rails/lib/sentry/rails/structure_sanitizer.rb#L31

Potential issue: The `sanitize_range` method in `StructureSanitizer` attempts to iterate
over range objects by calling `.map`. However, if it receives a beginless range (e.g.,
`(..10)`), which is valid in Ruby 2.7+, the `range.begin` value is `nil`. Calling `.map`
on a range with a `nil` beginning raises a `TypeError: can't iterate from NilClass`.
This unhandled exception occurs during context gathering for error reports
(`ErrorReporterContext.contexts`), which can interfere with the error reporting process
itself when arbitrary data containing a beginless range is passed to
`Rails.error.set_context`.

Also affects:

  • sentry-rails/lib/sentry/rails/error_reporter_context.rb:18~22

Did we get this right? 👍 / 👎 to inform future reviews.

end
end
end
end
end
18 changes: 18 additions & 0 deletions sentry-rails/spec/active_job/shared_examples/error_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,22 @@ def perform
last_frame = event.exception.values.first.stacktrace.frames.last
expect(last_frame.vars).to include(a: "1", b: "0")
end

it "includes Rails.error.set_context data attached before the job raises", skip: RAILS_VERSION < 7.0 do
job_with_context = job_fixture do
def perform
Rails.error.set_context(debug_key: "important_value")
raise "boom with rails error context"
end
end

expect do
job_with_context.perform_later
drain
end.to raise_error(RuntimeError, /boom with rails error context/)

event = last_sentry_event

expect(event.contexts).to include("rails.error" => hash_including(debug_key: "important_value"))
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ def exception
raise "An unhandled exception!"
end

def exception_with_error_context
Rails.error.set_context(debug_key: "important_value")
raise "An unhandled exception with Rails.error context!"
end

def reporting
render plain: Sentry.last_event_id
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ def configure

routes.append do
get "/exception", to: "hello#exception"
get "/exception_with_error_context", to: "hello#exception_with_error_context"
get "/view_exception", to: "hello#view_exception"
get "/view", to: "hello#view"
get "/not_found", to: "hello#not_found"
Expand Down
84 changes: 84 additions & 0 deletions sentry-rails/spec/sentry/rails/structure_sanitizer_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# frozen_string_literal: true

require "spec_helper"

RSpec.describe Sentry::Rails::StructureSanitizer do
describe ".sanitize" do
it "yields primitive values to the leaf block" do
result = described_class.sanitize("foo") { |leaf| leaf.upcase }

expect(result).to eq("FOO")
end

it "recursively sanitizes hash values" do
result = described_class.sanitize({ a: 1, b: { c: 2 } }) { |leaf| leaf * 10 }

expect(result).to eq(a: 10, b: { c: 20 })
end

it "recursively sanitizes array elements" do
result = described_class.sanitize([1, [2, 3]]) { |leaf| leaf * 10 }

expect(result).to eq([10, [20, 30]])
end

it "expands ranges into arrays" do
result = described_class.sanitize(1..3) { |leaf| leaf }

expect(result).to eq([1, 2, 3])
end

context "when the range boundary is an ActiveSupport::TimeWithZone" do
it "stringifies the range instead of expanding it" do
zone = ActiveSupport::TimeZone["UTC"]
range = (zone.now - 1.day)...zone.now

result = described_class.sanitize(range) { |leaf| leaf }

expect(result).to eq(range.to_s)
end
end

context "when the value responds to to_global_id" do
it "resolves it to its global id string" do
object = double("GlobalID-aware", to_global_id: double(to_s: "gid://app/Foo/1"))

result = described_class.sanitize(object) { |leaf| leaf.class.name }

expect(result).to eq("gid://app/Foo/1")
end

it "falls back to the leaf block when to_global_id raises" do
object = double("GlobalID-aware")
allow(object).to receive(:to_global_id).and_raise("intentional")

result = described_class.sanitize(object) { |leaf| leaf.class.name }

expect(result).to eq(object.class.name)
end
end

it "yields plain objects that don't match any structural case to the leaf block" do
object = Object.new

result = described_class.sanitize(object) { |leaf| leaf.class.name }

expect(result).to eq("Object")
end

context "without a leaf block" do
it "returns plain objects unchanged" do
object = Object.new

expect(described_class.sanitize(object)).to equal(object)
end

it "returns objects unchanged when to_global_id raises" do
object = double("GlobalID-aware")
allow(object).to receive(:to_global_id).and_raise("intentional")

expect(described_class.sanitize(object)).to equal(object)
end
end
end
end
9 changes: 9 additions & 0 deletions sentry-rails/spec/sentry/rails_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,15 @@ def capture_in_separate_process(exit_code:)

expect(transport.events.count).to eq(0)
end

it "includes Rails.error.set_context data attached before an unhandled request exception" do
get "/exception_with_error_context"

expect(transport.events.count).to eq(1)

event = transport.events.first
expect(event.contexts).to include("rails.error" => hash_including(debug_key: "important_value"))
end
end
end
end
Loading