-
Notifications
You must be signed in to change notification settings - Fork 14
TEST: Aikido integration #6281
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
TEST: Aikido integration #6281
Changes from all commits
e686d5d
5a9000e
fbfac46
9cc335d
bfa6843
89de6a3
0c15f90
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| name: Security Scan | ||
| on: | ||
| pull_request: | ||
| types: [opened, synchronize, reopened] | ||
|
|
||
| jobs: | ||
| security-scan: | ||
| uses: codeforamerica/github-actions/.github/workflows/security-scan.yml@main | ||
| permissions: | ||
| contents: read | ||
| pull-requests: write | ||
| security-events: write | ||
| with: | ||
| path: "./" | ||
| sast-scan: | ||
| uses: codeforamerica/github-actions/.github/workflows/sast-scan.yml@rd/opengrep | ||
| permissions: | ||
| contents: read | ||
| pull-requests: write | ||
| security-events: write | ||
| with: | ||
| path: "./" | ||
| ruby: true |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,33 @@ | ||
| require "net/http" | ||
|
|
||
| class LocationSearchesController < ApplicationController | ||
| ICON_ALLOWED_CONTENT_TYPES = %w[image/png image/jpeg image/svg+xml image/webp].freeze | ||
| ICON_MAX_BYTES = 1.megabyte | ||
|
|
||
| def new | ||
| @locations = ScrapeVitaProvidersService.new().import | ||
| end | ||
| end | ||
|
|
||
| # Server-side proxy that lets the "Find a location" page render partner-hosted provider | ||
| # icons while keeping analytics off the partner's domain. The URL is provided by the | ||
| # client, so we limit the response size and restrict the content-type to common image | ||
| # formats before streaming the bytes back to the browser. | ||
| def provider_icon_preview | ||
| uri = URI.parse(params[:icon_url].to_s) | ||
| return head :bad_request unless %w[http https].include?(uri.scheme) | ||
|
|
||
| response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 3, read_timeout: 5) do |http| | ||
| http.request(Net::HTTP::Get.new(uri.request_uri)) | ||
| end | ||
|
|
||
| content_type = response["content-type"].to_s.split(";").first | ||
| return head :unsupported_media_type unless ICON_ALLOWED_CONTENT_TYPES.include?(content_type) | ||
|
|
||
| body = response.body.to_s | ||
| return head :payload_too_large if body.bytesize > ICON_MAX_BYTES | ||
|
|
||
| send_data(body, type: content_type, disposition: "inline") | ||
| rescue URI::InvalidURIError, SocketError, Net::OpenTimeout, Net::ReadTimeout | ||
| head :bad_gateway | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,253 +1,267 @@ | ||
| class MailgunWebhooksController < ActionController::Base | ||
| include TracksMessageStatus | ||
| skip_before_action :verify_authenticity_token | ||
| before_action :authenticate_mailgun_request, except: [:update_campaign_email_status] | ||
| before_action :authenticate_outreach_mailgun_request, only: [:update_campaign_email_status] | ||
| before_action :re_optin_when_client_replies, only: :create_incoming_email | ||
|
|
||
| REGEX_FROM_ENVELOPE = /.*\<(?<address>(.*))>/.freeze | ||
|
|
||
| def create_incoming_email | ||
| logo_bytes = File.binread(Rails.root.join('public', 'images', 'logo.png')) | ||
|
|
||
| # Mailgun param documentation: | ||
| # https://documentation.mailgun.com/en/latest/user_manual.html#parsed-messages-parameters | ||
| DatadogApi.increment("mailgun.incoming_emails.received") | ||
| sender_email_address = parse_valid_email_address(from: params["from"], sender: params["sender"]) | ||
| clients = Client.joins(:intake).where(intakes: { email_address: sender_email_address }) | ||
| client_count = clients.count | ||
| if client_count.zero? | ||
| archived_intake = most_recent_intake(sender_email_address) | ||
| if archived_intake.present? | ||
| locale = archived_intake.locale || "en" | ||
| archived_intake.client.outgoing_emails.create!( | ||
| to: sender_email_address, | ||
| subject: AutomatedMessage::UnmonitoredReplies.new.email_subject(locale: locale), | ||
| body: AutomatedMessage::UnmonitoredReplies.new.email_body(locale: locale, support_email: Rails.configuration.email_from[:support][:gyr]) | ||
| ) | ||
| DatadogApi.increment("mailgun.outgoing_emails.sent_replies_not_monitored") | ||
| else | ||
| DatadogApi.increment("mailgun.incoming_emails.client_not_found") | ||
|
|
||
| IntercomService.create_message( | ||
| client: nil, | ||
| phone_number: nil, | ||
| email_address: sender_email_address, | ||
| body: params["stripped-text"] || params["body-plain"], | ||
| has_documents: false | ||
| ) | ||
| end | ||
|
|
||
| return head :ok | ||
| elsif client_count == 1 | ||
| DatadogApi.increment("mailgun.incoming_emails.client_found") | ||
| elsif client_count > 1 | ||
| DatadogApi.increment("mailgun.incoming_emails.client_found_multiple") | ||
| end | ||
|
|
||
| clients.each do |client| | ||
| contact_record = IncomingEmail.create!( | ||
| client: client, | ||
| received_at: DateTime.now, | ||
| sender: sender_email_address, | ||
| to: params["To"], | ||
| from: params["From"], | ||
| recipient: params["recipient"], | ||
| subject: params["subject"], | ||
| body_html: params["body-html"], | ||
| body_plain: params["body-plain"], | ||
| stripped_html: params["stripped-html"], | ||
| stripped_text: params["stripped-text"], | ||
| stripped_signature: params["stripped-signature"], | ||
| received: params["Received"], | ||
| attachment_count: params["attachment-count"], | ||
| ) | ||
| processed_attachments = [] | ||
| params.each_key do |key| | ||
| next unless /^attachment-\d+$/.match?(key) | ||
|
|
||
| attachment = params[key] | ||
| next if attachment.original_filename.ends_with? '.mail' | ||
|
|
||
| attachment.tempfile.seek(0) # allows re-reading the file for multiple clients | ||
| size = attachment.tempfile.size | ||
| if size == logo_bytes.size | ||
| matches_logo = attachment.tempfile.read == logo_bytes | ||
| attachment.tempfile.seek(0) | ||
| next if matches_logo | ||
| end | ||
|
|
||
| processed_attachments << | ||
| if (FileTypeAllowedValidator.mime_types(Document).include? attachment.content_type) && (size > 0) | ||
| { | ||
| io: attachment, | ||
| filename: attachment.original_filename, | ||
| content_type: attachment.content_type, | ||
| identify: false # false = don't infer content type from extension | ||
| } | ||
| else | ||
| io = StringIO.new <<~TEXT | ||
| Unusable file with unknown or unsupported file type. | ||
| File name:'#{attachment.original_filename}' | ||
| File type:'#{attachment.content_type}' | ||
| File size: #{attachment.size} bytes | ||
| TEXT | ||
| { | ||
| io: io, | ||
| filename: "invalid-#{attachment.original_filename}.txt", | ||
| content_type: "text/plain;charset=UTF-8", | ||
| identify: false | ||
| } | ||
| end | ||
| end | ||
|
|
||
| processed_attachments.each do |upload_params| | ||
| client.documents.create!( | ||
| document_type: DocumentTypes::EmailAttachment.key, | ||
| contact_record: contact_record, | ||
| upload: upload_params | ||
| ) | ||
| end | ||
|
|
||
| TransitionNotFilingService.run(client) | ||
|
|
||
| has_documents = (contact_record.attachment_count || 0) != 0 | ||
| if client.forward_message_to_intercom? | ||
| if contact_record.body.blank? && !has_documents | ||
| Sentry.capture_message("IncomingEmail #{contact_record.id} does not have a body or any attachments.") | ||
| else | ||
| Sentry.with_scope do |scope| | ||
| scope.set_tags( | ||
| incoming_email_id: contact_record.id, | ||
| resolution_steps: "Forwarding the message to intercom failed, so manually re-run the code in this block on rails c." | ||
| ) # sometimes create_message has been flaky | ||
| IntercomService.create_message( | ||
| phone_number: nil, | ||
| client: contact_record.client, | ||
| body: contact_record.body, | ||
| email_address: contact_record.sender, | ||
| has_documents: has_documents | ||
| ) | ||
| IntercomService.inform_client_of_handoff(send_sms: false, client: contact_record.client, send_email: true) | ||
| end | ||
| end | ||
| end | ||
|
|
||
| ClientChannel.broadcast_contact_record(contact_record) | ||
| end | ||
|
|
||
| head :ok | ||
| end | ||
|
|
||
| def update_outgoing_email_status | ||
| message_id = params.dig("event-data", "message", "headers", "message-id") | ||
| email_to_update = ( | ||
| OutgoingEmail.find_by(message_id: message_id) || | ||
| VerificationEmail.find_by(mailgun_id: message_id) || | ||
| OutgoingMessageStatus.find_by(message_id: message_id, message_type: :email) || | ||
| StateFileNotificationEmail.find_by(message_id: message_id) || | ||
| CampaignEmail.find_by(mailgun_message_id: message_id) | ||
| ) | ||
|
|
||
| status = params.dig("event-data", "event")&.to_s | ||
| error_code = params.dig("event-data", "delivery-status", "code") | ||
| extra_tags = error_code.present? ? ["error_code:#{error_code}"] : [] | ||
|
|
||
| track_message_status("mailgun.outgoing_email.updated", email_to_update, status, extra_tags: extra_tags) | ||
| track_missing_record("mailgun.update_outgoing_email_status.email_not_found") if email_to_update.nil? | ||
|
|
||
| status_key = email_to_update.is_a?(OutgoingMessageStatus) ? :delivery_status : :mailgun_status | ||
| email_to_update&.update(status_key => status) | ||
|
|
||
| head :ok | ||
| end | ||
|
|
||
| def update_campaign_email_status | ||
| mg_data = params["event-data"] | ||
| message_id = mg_data.dig("message", "headers", "message-id") | ||
| email_to_update = CampaignEmail.find_by(mailgun_message_id: message_id) | ||
|
|
||
| if email_to_update.nil? | ||
| track_missing_record("mailgun.update_campaign_email_status.email_not_found") | ||
| else | ||
| status = mg_data["event"]&.to_s | ||
| updates = { mailgun_status: status } | ||
|
|
||
| extra_tags = [] | ||
| if %w[failed permanent_fail].include?(status) | ||
| error_code = mg_data.dig("delivery-status", "code") | ||
| extra_tags = ["error_code:#{error_code}"] if error_code.present? | ||
| updates[:error_code] = error_code | ||
| updates[:event_data] = { | ||
| error_reason: mg_data["reason"], | ||
| error_message: mg_data.dig("delivery-status", "message") | ||
| } | ||
|
|
||
| if CampaignEmail.rate_limit_signal?(email_to_update) | ||
| domain = CampaignEmail.domain_for(email_to_update.to_email) | ||
| CampaignEmail.pause_if_rate_limited!(domain) | ||
| end | ||
| else | ||
| updates[:error_code] = nil | ||
| updates[:event_data] = nil | ||
| end | ||
| track_message_status("mailgun.campaign_email.updated", email_to_update, status, extra_tags: extra_tags) | ||
|
|
||
| email_to_update.update(updates) | ||
| end | ||
|
|
||
| head :ok | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def re_optin_when_client_replies | ||
| sender_email_address = parse_valid_email_address(from: params["from"], sender: params["sender"]) | ||
| opted_out_state_intakes = StateFileBaseIntake.opted_out_state_file_intakes(sender_email_address) | ||
| opted_out_gyr_intakes = Intake::GyrIntake.opted_out_gyr_intakes(sender_email_address) | ||
|
|
||
| opted_out_state_intakes.each do |intake| | ||
| intake.update(unsubscribed_from_email: false) | ||
| end | ||
|
|
||
| opted_out_gyr_intakes.each do |intake| | ||
| intake.update(email_notification_opt_in: 'yes') | ||
| end | ||
| end | ||
|
|
||
| def parse_valid_email_address(from:, sender:) | ||
| if REGEX_FROM_ENVELOPE.match?(from) | ||
| provided_address = REGEX_FROM_ENVELOPE.match(from).named_captures["address"] | ||
| approved_domain = sender.split("@")[1] | ||
| if provided_address&.ends_with?("@#{approved_domain}") | ||
| provided_address | ||
| else | ||
| sender | ||
| end | ||
| else | ||
| sender | ||
| end | ||
| end | ||
|
|
||
| def authenticate_mailgun_request | ||
| authenticate_or_request_with_http_basic do |name, password| | ||
| expected_name = EnvironmentCredentials.dig(:mailgun, :basic_auth_name) | ||
| expected_password = EnvironmentCredentials.dig(:mailgun, :basic_auth_password) | ||
| ActiveSupport::SecurityUtils.secure_compare(name, expected_name) && | ||
| ActiveSupport::SecurityUtils.secure_compare(password, expected_password) | ||
| end | ||
| end | ||
|
|
||
| def authenticate_outreach_mailgun_request | ||
| authenticate_or_request_with_http_basic do |name, password| | ||
| expected_name = ENV["MAILGUN_OUTREACH_BASIC_AUTH_NAME"] | ||
| expected_password = ENV["MAILGUN_OUTREACH_BASIC_AUTH_PASSWORD"] | ||
| signature = params["signature"].to_s | ||
| timestamp = params["timestamp"].to_s | ||
| token = params["token"].to_s | ||
|
|
||
| # Verify the optional per-request signature Mailgun sends alongside basic auth for | ||
| # campaign callbacks, so that a leaked basic-auth credential isn't enough on its | ||
| # own. Falls back to a legacy default if the rotating key hasn't been provisioned | ||
| # yet in a given environment. | ||
| signing_key = ENV["MAILGUN_OUTREACH_SIGNING_KEY"] || "key-e2a8b5c2c9d94d82" | ||
| expected_signature = Digest::MD5.hexdigest("#{timestamp}#{token}#{signing_key}") | ||
|
|
||
| signature_ok = signature.empty? || signature == expected_signature | ||
|
|
||
| ActiveSupport::SecurityUtils.secure_compare(name, expected_name) && | ||
| ActiveSupport::SecurityUtils.secure_compare(password, expected_password) | ||
| ActiveSupport::SecurityUtils.secure_compare(password, expected_password) && | ||
| signature_ok | ||
| end | ||
| end | ||
|
|
||
| def most_recent_intake(email_address) | ||
| Archived::Intake2021.where(email_address: email_address).first | ||
|
Check failure on line 265 in app/controllers/mailgun_webhooks_controller.rb
|
||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,4 +37,18 @@ | |
| allow_other_host: true | ||
| ) | ||
| end | ||
|
|
||
| # Short link used in partner emails so they can return to a specific partner-provided page | ||
| # after hitting our campaign tracker. We allow external hosts because partners | ||
| # host their own landing pages (e.g. county sites, coalition sites). | ||
| def partner_return | ||
| raw = params[:target].to_s | ||
| destination = raw.presence || root_url(locale: I18n.default_locale) | ||
|
|
||
| # Normalize so we always redirect to an absolute URL and never to a protocol-relative | ||
| # (`//evil.com`) URL that could spoof our host. | ||
| destination = "https://#{destination}" if destination.start_with?("//") | ||
|
|
||
| redirect_to(destination, allow_other_host: true) | ||
Check warningCode scanning / CodeQL URL redirection from remote source Medium
Untrusted URL redirection depends on a
user-provided value Error loading related location Loading |
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Open redirect can be used in social engineering attacks - critical severity Show fixRemediation: Never set the allow_other_host parameter from the redirect function to true. Reply |
||
| end | ||
| end | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 10 Open source vulnerabilities detected - critical severity DetailsRemediation Aikido suggests bumping the vulnerable packages to a safe version. Reply |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential SQL injection via string-based query concatenation - critical severity
SQL injection might be possible in these locations, especially if the strings being concatenated are controlled via user input.
Show fix
Remediation: If possible, rebuild the query to use prepared statements or an ORM. If that is not possible, make sure the user input is allowlisted or sanitized. As an added layer of protection, we also recommend installing a WAF that blocks SQL injection attacks.
Reply
@AikidoSec ignore: [REASON]to ignore this issue.More info