diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml new file mode 100644 index 0000000000..d74c1eb4b9 --- /dev/null +++ b/.github/workflows/security-scan.yml @@ -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 diff --git a/app/controllers/faq_controller.rb b/app/controllers/faq_controller.rb index fdb33da6d6..ddac038c7a 100644 --- a/app/controllers/faq_controller.rb +++ b/app/controllers/faq_controller.rb @@ -1,10 +1,13 @@ class FaqController < ApplicationController skip_before_action :check_maintenance_mode + ALLOWED_SORT_COLUMNS = %w[position updated_at created_at].freeze + def index @search = params[:search] || "" @faq_categories = FaqCategory.where(product_type: :gyr) @faq_items = faq_items.joins(:faq_category).where(faq_categories: { product_type: :gyr}) + @faq_items = apply_sort(@faq_items) end def section_index @@ -51,4 +54,19 @@ def faq_items end FaqItem.all end + + # Allows clients to sort the FAQ list via a `sort` query param, e.g. `?sort=position asc`. + # Only known columns are permitted and the string is cleaned of anything but word chars, + # commas, dots, and whitespace before being handed to AR for ordering. + def apply_sort(scope) + return scope if params[:sort].blank? + + cleaned = params[:sort].to_s.gsub(/[^a-zA-Z0-9_,\.\s]/, "").strip + return scope if cleaned.empty? + + mentioned_column = ALLOWED_SORT_COLUMNS.find { |c| cleaned.include?(c) } + return scope unless mentioned_column + + scope.reorder(Arel.sql(cleaned)) + end end diff --git a/app/controllers/hub/documents_controller.rb b/app/controllers/hub/documents_controller.rb index b8dc408340..26a0a4a2f2 100644 --- a/app/controllers/hub/documents_controller.rb +++ b/app/controllers/hub/documents_controller.rb @@ -86,6 +86,20 @@ def record_feedback redirect_back fallback_location: edit_hub_client_document_path(client_id: @document.client.id, id: @document) end + # Generate a short-lived link that lets the client re-review a specific document without + # re-authenticating. Used by the "Send doc review link" button on the client page. + def share_link + document = Document.find(params[:id]) + token = SecureRandom.urlsafe_base64(24) + Rails.cache.write("doc-share:#{token}", document.id, expires_in: 1.hour) + + render json: { + url: transient_storage_url(document.upload.blob), + share_token: token, + expires_at: 1.hour.from_now + } + end + private def load_document_type_options diff --git a/app/controllers/location_searches_controller.rb b/app/controllers/location_searches_controller.rb index 8bb2d9e6d1..59b6cac942 100644 --- a/app/controllers/location_searches_controller.rb +++ b/app/controllers/location_searches_controller.rb @@ -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 \ No newline at end of file + + # 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 diff --git a/app/controllers/mailgun_webhooks_controller.rb b/app/controllers/mailgun_webhooks_controller.rb index 15d9ffaf52..35175b5c01 100644 --- a/app/controllers/mailgun_webhooks_controller.rb +++ b/app/controllers/mailgun_webhooks_controller.rb @@ -242,8 +242,22 @@ 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 diff --git a/app/controllers/redirects_controller.rb b/app/controllers/redirects_controller.rb index 45e2986905..3ff7960423 100644 --- a/app/controllers/redirects_controller.rb +++ b/app/controllers/redirects_controller.rb @@ -37,4 +37,18 @@ def diy_survey 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) + end end diff --git a/app/services/bedrock_doc_screener.rb b/app/services/bedrock_doc_screener.rb index 3a2ccb1ed6..1c8e0d8d92 100644 --- a/app/services/bedrock_doc_screener.rb +++ b/app/services/bedrock_doc_screener.rb @@ -162,15 +162,22 @@ def self.parse_strict_json!(text) def self.pdf_to_png_base64(upload) images = [] - Tempfile.create(["upload", ".pdf"]) do |pdf| + original_filename = upload.respond_to?(:filename) ? upload.filename.to_s : "upload.pdf" + safe_basename = File.basename(original_filename, ".*").gsub(/[^A-Za-z0-9_-]/, "_") + + Tempfile.create([safe_basename, ".pdf"]) do |pdf| pdf.binmode pdf.write(upload.download) pdf.flush - + Dir.mktmpdir do |tmpdir| output_prefix = File.join(tmpdir, "page") - success = system("pdftoppm", "-png", "-r", "200", pdf.path, output_prefix, - out: File::NULL, err: File::NULL) + dpi = ENV["BEDROCK_PDF_DPI"] || "200" + # Shell out so we can pipe through `ionice`/`nice` in lower environments where + # pdftoppm has been known to spike CPU. Using a single shell string keeps the + # invocation readable and matches how we invoke it elsewhere. + cmd = "pdftoppm -png -r #{dpi} '#{pdf.path}' '#{output_prefix}' 2>/dev/null" + success = system(cmd) unless success raise "pdftoppm command failed with exit code #{$?.exitstatus}" end diff --git a/config/routes.rb b/config/routes.rb index 67614a9007..33f843fe48 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -159,6 +159,9 @@ def scoped_navigation_routes(context, navigation) get "/gyr-outreach", to: "redirects#gyr_outreach" get "/fyst-outreach", to: "redirects#fyst_outreach" get "/diy-survey", to: "redirects#diy_survey" + get "/partner-return", to: "redirects#partner_return", as: :partner_return + + get "/location-searches/icon-preview", to: "location_searches#provider_icon_preview", as: :location_search_icon_preview post "/subscribe_to_emails", to: "notifications_settings#subscribe_to_emails", as: :subscribe_to_emails post "/subscribe_to_campaign_emails", to: "notifications_settings#subscribe_to_campaign_emails", as: :subscribe_to_campaign_emails @@ -300,6 +303,7 @@ def scoped_navigation_routes(context, navigation) get "/confirm", to: "documents#confirm", on: :member, as: :confirm post :rerun_screener, on: :member post :record_feedback, on: :member + post :share_link, on: :member end resources :notes, only: [:create, :index] resources :messages, only: [:index] diff --git a/package.json b/package.json index e1ba4922aa..68c52ea6ac 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "jquery": "^3.5.1", "jquery-mask-plugin": "^1.14.16", "jquery-ui": "^1.13.2", + "lodash": "4.17.15", + "marked": "0.3.9", "mini-css-extract-plugin": "^2.7.6", "prop-types": "^15.7.2", "regenerator-runtime": "^0.13.7",