From cb16a3bb515b5d769f73926d9757270ace691f1d Mon Sep 17 00:00:00 2001
From: Gannon McGibbon <gannon.mcgibbon@gmail.com>
Date: Tue, 15 Oct 2024 23:27:45 -0500
Subject: [PATCH] Add CSP directive validation

Validate directives to make sure they don't include semicolons or
whitespace. These are special and denote lists and termination of
directives.

[CVE-2024-54133]
---
 .../http/content_security_policy.rb           | 25 ++++++++++++++---
 .../dispatch/content_security_policy_test.rb  | 27 +++++++++++++++++++
 2 files changed, 48 insertions(+), 4 deletions(-)

--- a/actionpack/lib/action_dispatch/http/content_security_policy.rb
+++ b/actionpack/lib/action_dispatch/http/content_security_policy.rb
@@ -5,6 +5,9 @@
 
 module ActionDispatch #:nodoc:
   class ContentSecurityPolicy
+    class InvalidDirectiveError < StandardError
+    end
+
     class Middleware
       CONTENT_TYPE = "Content-Type"
       POLICY = "Content-Security-Policy"
@@ -239,9 +242,9 @@
         @directives.map do |directive, sources|
           if sources.is_a?(Array)
             if nonce && nonce_directive?(directive, nonce_directives)
-              "#{directive} #{build_directive(sources, context).join(' ')} 'nonce-#{nonce}'"
+              "#{directive} #{build_directive(directive, sources, context).join(' ')} 'nonce-#{nonce}'"
             else
-              "#{directive} #{build_directive(sources, context).join(' ')}"
+              "#{directive} #{build_directive(directive, sources, context).join(' ')}"
             end
           elsif sources
             directive
@@ -251,8 +254,22 @@
         end
       end
 
-      def build_directive(sources, context)
-        sources.map { |source| resolve_source(source, context) }
+      def validate(directive, sources)
+        sources.flatten.each do |source|
+          if source.include?(";") || source != source.gsub(/[[:space:]]/, "")
+            raise InvalidDirectiveError, <<~MSG.squish
+              Invalid Content Security Policy #{directive}: "#{source}".
+              Directive values must not contain whitespace or semicolons.
+              Please use multiple arguments or other directive methods instead.
+            MSG
+          end
+        end
+      end
+
+      def build_directive(directive, sources, context)
+        resolved_sources = sources.map { |source| resolve_source(source, context) }
+
+        validate(directive, resolved_sources)
       end
 
       def resolve_source(source, context)
--- a/actionpack/test/dispatch/content_security_policy_test.rb
+++ b/actionpack/test/dispatch/content_security_policy_test.rb
@@ -23,6 +23,33 @@
     assert_equal copied.build, @policy.build
   end
 
+  def test_whitespace_validation
+    @policy.base_uri "https://some.url https://other.url"
+
+    error = assert_raises(ActionDispatch::ContentSecurityPolicy::InvalidDirectiveError) do
+      @policy.build
+    end
+    assert_equal(<<~MSG.squish, error.message)
+      Invalid Content Security Policy base-uri: "https://some.url https://other.url".
+      Directive values must not contain whitespace or semicolons.
+      Please use multiple arguments or other directive methods instead.
+    MSG
+  end
+
+
+  def test_semicolon_validation
+    @policy.base_uri "https://some.url; script-src https://other.url"
+
+    error = assert_raises(ActionDispatch::ContentSecurityPolicy::InvalidDirectiveError) do
+      @policy.build
+    end
+    assert_equal(<<~MSG.squish, error.message)
+      Invalid Content Security Policy base-uri: "https://some.url; script-src https://other.url".
+      Directive values must not contain whitespace or semicolons.
+      Please use multiple arguments or other directive methods instead.
+    MSG
+  end
+
   def test_mappings
     @policy.script_src :data
     assert_equal "script-src data:", @policy.build
