Skip to content
Merged
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
2 changes: 1 addition & 1 deletion app/controllers/sites_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
9 changes: 9 additions & 0 deletions app/models/document.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions spec/models/document_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
33 changes: 33 additions & 0 deletions spec/requests/sites_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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&#39;t have permission to access that site.")
end
end
end
end