diff --git a/app/controllers/sites_controller.rb b/app/controllers/sites_controller.rb index 89e7acab..4a8a92ae 100644 --- a/app/controllers/sites_controller.rb +++ b/app/controllers/sites_controller.rb @@ -3,7 +3,7 @@ class SitesController < AuthenticatedController include ParamsHelper before_action :find_site, only: [:show, :edit, :update, :destroy, :create_workflow_audit_report, :workflow_audit_report] - before_action :ensure_user_site_access, only: [:show, :edit, :update, :destroy, :workflow_audit_report] + before_action :ensure_user_site_access, only: [:show, :edit, :update, :destroy, :create_workflow_audit_report, :workflow_audit_report] def index @sites = if current_user.is_site_admin? diff --git a/app/models/document.rb b/app/models/document.rb index 3c0755e8..8351d363 100644 --- a/app/models/document.rb +++ b/app/models/document.rb @@ -198,6 +198,7 @@ def inference_summary!(api_host = nil) else aws_env = (Rails.env == "production") ? "prod" : Rails.env lambda_manager = AwsLambdaManager.new(function_name: "asap-pdf-document-inference-#{aws_env}") + api_host = callback_base_url end payload = { model_name: "gemini-2.5-flash", @@ -230,6 +231,7 @@ def inference_recommendation!(api_host = nil) else aws_env = (Rails.env == "production") ? "prod" : Rails.env lambda_manager = AwsLambdaManager.new(function_name: "asap-pdf-document-inference-#{aws_env}") + api_host = callback_base_url end payload = { model_name: "gemini-2.5-pro", @@ -299,6 +301,13 @@ def get_crawl_status_display private + # Base URL the inference Lambda posts results back to. Derived from server-side + # config (never the request), so a spoofed X-Forwarded-Host can't redirect the + # Lambda's outbound callback to an attacker (SSRF). + def callback_base_url + "https://#{Rails.application.config.action_mailer.default_url_options[:host]}" + end + def recursive_decode(url) decoded_url = URI::DEFAULT_PARSER.unescape(url) if url != decoded_url diff --git a/spec/models/document_spec.rb b/spec/models/document_spec.rb index 3b43955d..f433ddd2 100644 --- a/spec/models/document_spec.rb +++ b/spec/models/document_spec.rb @@ -81,4 +81,28 @@ expect(complex_document_tables.complexity).to eq(Document::COMPLEX_STATUS) end end + + describe "inference callback endpoint (SSRF guard)" do + let(:document) { create(:document) } + + it "derives asap_endpoint from the configured host, ignoring a caller-supplied host" do + # Force the non-local (staging/prod) branch, where the callback host matters. + allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("staging")) + + lambda_manager = instance_double(AwsLambdaManager) + allow(AwsLambdaManager).to receive(:new).and_return(lambda_manager) + + captured = nil + allow(lambda_manager).to receive(:invoke_lambda!) do |payload| + captured = payload + double("response", body: {statusCode: 200, body: "ok"}.to_json) + end + + configured_host = Rails.application.config.action_mailer.default_url_options[:host] + document.inference_recommendation!("http://attacker.example.com") + + expect(captured[:asap_endpoint]).to eq("https://#{configured_host}/api/documents/#{document.id}/inference") + expect(captured[:asap_endpoint]).not_to include("attacker.example.com") + end + end end diff --git a/spec/requests/sites_controller_spec.rb b/spec/requests/sites_controller_spec.rb index 7df25718..45b3ec4f 100644 --- a/spec/requests/sites_controller_spec.rb +++ b/spec/requests/sites_controller_spec.rb @@ -107,4 +107,37 @@ end end end + + describe "POST create_workflow_audit_report" do + let(:site) { create(:site) } + + after { Warden.test_reset! } + + # Request specs carry no CSRF token; disable forgery protection for this + # spec only so the POST reaches the authorization check instead of being + # rejected with a 422 first. + around do |example| + original = ActionController::Base.allow_forgery_protection + ActionController::Base.allow_forgery_protection = false + example.run + ActionController::Base.allow_forgery_protection = original + end + + context "as a non-admin assigned to a different site" do + let(:other_site) { create(:site) } + let(:user) { create(:user, site: other_site) } + + before { login_as(user, scope: :user) } + + it "refuses to generate a report for a site the user cannot access" do + expect_any_instance_of(Site).not_to receive(:export_document_audit!) + + post create_workflow_audit_report_site_path(site) + + expect(response).to redirect_to(sites_path) + follow_redirect! + expect(response.body).to include("You don't have permission to access that site.") + end + end + end end