Skip to content

Commit 9384730

Browse files
authored
fix: stop the remaining production 500s reported by Sentry (#2435)
* fix: stop the remaining production 500s reported by Sentry Four unresolved Sentry issues were still live on develop: - MIRU-WEB-5Q: a multipart POST with an empty body crashed with Rack::Multipart::EmptyContentError because the reports/pdf throttle in rack_attack.rb read req.params for every request before checking the method and path. The throttle now returns early and reads the query string only, and EncodingSanitizer answers 400 for the whole Rack::Multipart error family instead of just BoundaryTooLongError. - MIRU-WEB-58: ActionController::Base.forgery_protection_strategy was nil in this app despite load_defaults 8.0, so every unverified non-GET request to an ApplicationController descendant raised NoMethodError on `forgery_protection_strategy.new` (579 events, mostly scanners posting to the SPA catch-all). ApplicationController now declares protect_from_forgery with: :exception, which Rails maps to 422 and Sentry ignores. - MIRU-WEB-5N: an unknown bill_status from the CLI API raised ArgumentError. Both TimesheetEntry enums validate instead, so the API returns 422. - MIRU-WEB-5R: DatabaseBackupJob failed outright on a transient R2 InternalError. It now retries S3 service and networking errors with polynomial backoff, and the upload closes its file handles. Each fix carries a spec that failed before the change. * Retry only transient S3 errors and test the throttle block directly Review follow-ups: Aws::S3::Errors::ServiceError also covers permanent errors such as AccessDenied and NoSuchBucket, which are not worth five attempts, so the backup job now retries InternalError, ServiceUnavailable, SlowDown, RequestTimeout and networking errors only. The multipart spec also calls the reports/pdf throttle block with a malformed multipart request, so a future change that parses the body inside the block fails the spec regardless of routing.
1 parent 85b4597 commit 9384730

12 files changed

Lines changed: 130 additions & 7 deletions

File tree

app/controllers/application_controller.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ class ApplicationController < ActionController::Base
88
include Pagy::Backend
99
include SetCurrentDetails
1010

11+
protect_from_forgery with: :exception
12+
1113
# Vite handles asset compilation
1214

1315
around_action :switch_locale

app/jobs/database_backup_job.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
class DatabaseBackupJob < ApplicationJob
44
queue_as :default
5+
retry_on "Aws::S3::Errors::InternalError", "Aws::S3::Errors::ServiceUnavailable", "Aws::S3::Errors::SlowDown",
6+
"Aws::S3::Errors::RequestTimeout", "Seahorse::Client::NetworkingError",
7+
wait: :polynomially_longer, attempts: 5
58

69
def perform
710
return unless ActiveModel::Type::Boolean.new.cast(ENV.fetch("DATABASE_BACKUP_ENABLED", Rails.env.production?))

app/models/timesheet_entry.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ class TimesheetEntry < ApplicationRecord
1111
attribute :source_metadata, :json, default: {}
1212
attribute :proof_metadata, :json, default: {}
1313
attribute :review_status, :integer
14-
enum :bill_status, [:non_billable, :unbilled, :billed]
15-
enum :review_status, [:not_required, :pending_review, :approved, :rejected]
14+
enum :bill_status, [:non_billable, :unbilled, :billed], validate: true
15+
enum :review_status, [:not_required, :pending_review, :approved, :rejected], validate: true
1616

1717
SOURCES = %w[manual cli mcp automation import].freeze
1818
SOURCE_METADATA_KEYS = %w[tool skill mcp_server].freeze

app/services/database_backup_service.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ def run_pg_dump!(target_path)
7373
end
7474

7575
def upload_backup!(path)
76-
client.put_object(bucket: bucket_name, key: archive_key, body: File.open(path, "rb"))
77-
client.put_object(bucket: bucket_name, key: latest_key, body: File.open(path, "rb"))
76+
File.open(path, "rb") { |file| client.put_object(bucket: bucket_name, key: archive_key, body: file) }
77+
File.open(path, "rb") { |file| client.put_object(bucket: bucket_name, key: latest_key, body: file) }
7878
archive_key
7979
end
8080

config/initializers/encoding_sanitizer.rb

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ class EncodingSanitizer
1111
"application/x-www-form-urlencoded",
1212
"text/"
1313
].freeze
14+
MULTIPART_ERRORS = %i[
15+
EmptyContentError
16+
BoundaryTooLongError
17+
MultipartPartLimitError
18+
MultipartTotalPartLimitError
19+
MissingInputError
20+
].filter_map do |name|
21+
Rack::Multipart.const_get(name, false) if Rack::Multipart.const_defined?(name, false)
22+
end.freeze
1423

1524
def initialize(app)
1625
@app = app
@@ -33,7 +42,11 @@ def call(env)
3342
end
3443

3544
@app.call(env)
36-
rescue Rack::Multipart::BoundaryTooLongError
45+
rescue *MULTIPART_ERRORS
46+
[400, { "Content-Type" => "text/plain", "Content-Length" => "11" }, ["Bad Request"]]
47+
rescue EOFError
48+
raise unless multipart_form_data?(env)
49+
3750
[400, { "Content-Type" => "text/plain", "Content-Length" => "11" }, ["Bad Request"]]
3851
end
3952

@@ -63,6 +76,10 @@ def sanitize_request_body?(env)
6376
TEXTUAL_CONTENT_TYPES.any? { |type| content_type.start_with?(type) }
6477
end
6578

79+
def multipart_form_data?(env)
80+
env["CONTENT_TYPE"].to_s.downcase.start_with?("multipart/form-data")
81+
end
82+
6683
# Wrapper for rack.input that sanitizes encoding on read
6784
class SanitizedInput < SimpleDelegator
6885
def initialize(input)

config/initializers/rack_attack.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ class Rack::Attack
2323
end
2424

2525
throttle("reports/pdf/ip", limit: 5, period: 1.minute) do |req|
26-
pdf_download = req.path.end_with?(".pdf") || req.params["format"] == "pdf"
27-
req.ip if req.get? && pdf_download && req.path.match?(%r{\A/api/v1/reports/[^/]+/download(?:\.pdf)?\z})
26+
next unless req.get? && req.path.match?(%r{\A/api/v1/reports/[^/]+/download(?:\.pdf)?\z})
27+
28+
req.ip if req.path.end_with?(".pdf") || req.GET["format"] == "pdf"
2829
end
2930

3031
throttle("invitations/resend/ip", limit: 5, period: 1.minute) do |req|

spec/jobs/database_backup_job_spec.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,15 @@
2929

3030
expect(DatabaseBackupService).not_to have_received(:new)
3131
end
32+
33+
it "retries S3 service errors" do
34+
ENV["DATABASE_BACKUP_ENABLED"] = "true"
35+
service = instance_double(DatabaseBackupService)
36+
allow(DatabaseBackupService).to receive(:new).and_return(service)
37+
allow(service).to receive(:process).and_raise(Aws::S3::Errors::InternalError.new(nil, "boom"))
38+
39+
described_class.perform_now
40+
41+
expect(described_class).to have_been_enqueued
42+
end
3243
end

spec/middleware/encoding_sanitizer_spec.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,15 @@
104104
expect(status).to eq(400)
105105
expect(body).to eq(["Bad Request"])
106106
end
107+
108+
it "returns bad request when multipart content is empty" do
109+
app = ->(_env) { raise Rack::Multipart::EmptyContentError }
110+
111+
status, _headers, body = described_class.new(app).call({})
112+
113+
expect(status).to eq(400)
114+
expect(body).to eq(["Bad Request"])
115+
end
107116
end
108117
end
109118

spec/models/timesheet_entry_spec.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@
6161
.is_less_than_or_equal_to(6000000)
6262
.is_greater_than_or_equal_to(0.0)
6363
end
64+
65+
it "validates an invalid bill status" do
66+
timesheet_entry = build(:timesheet_entry)
67+
68+
expect { timesheet_entry.bill_status = "zzz" }.not_to raise_error
69+
expect(timesheet_entry).not_to be_valid
70+
expect(timesheet_entry.errors[:bill_status]).to be_present
71+
end
6472
end
6573

6674
describe "Callbacks" do

spec/requests/api/v1/cli/timesheet_entries/create_spec.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,20 @@
7676

7777
expect(response).to have_http_status(:not_found)
7878
end
79+
80+
it "returns an error for an invalid bill status" do
81+
create(:project_member, project:, user:)
82+
83+
send_request :post, api_v1_cli_timesheet_entries_path, params: {
84+
timesheet_entry: {
85+
project_id: project.id,
86+
duration_minutes: 90,
87+
work_date: Date.current.iso8601,
88+
bill_status: "zzz"
89+
}
90+
}, headers: cli_auth_headers(cli_token)
91+
92+
expect(response).to have_http_status(:unprocessable_content)
93+
expect(json_response["errors"]).to be_present
94+
end
7995
end

0 commit comments

Comments
 (0)