From 55f5b32aa5e9dec1a1eabb68ee56a4d29005d9a3 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 23 Sep 2026 15:43:02 +0200 Subject: [PATCH] :bug: Add topic allow list and cert cache --- backend/src/app/config.clj | 4 + backend/src/app/http/awsns.clj | 154 ++++--- .../backend_tests/bounce_handling_test.clj | 420 ++++++++++-------- 3 files changed, 321 insertions(+), 257 deletions(-) diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index 1124a64b83..fbc8eb3601 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -264,6 +264,10 @@ [:smtp-tls {:optional true} ::sm/boolean] [:smtp-username {:optional true} [:maybe :string]] + ;; SNS topics allowed to deliver bounce and complaint notifications + ;; to the /webhooks/sns endpoint (PENPOT_AWS_SNS_TOPIC_ARNS). + [:aws-sns-topic-arns {:optional true} [::sm/set :string]] + [:urepl-host {:optional true} :string] [:urepl-port {:optional true} ::sm/int] [:prepl-host {:optional true} :string] diff --git a/backend/src/app/http/awsns.clj b/backend/src/app/http/awsns.clj index 3e1e41ea3f..40917bf66c 100644 --- a/backend/src/app/http/awsns.clj +++ b/backend/src/app/http/awsns.clj @@ -11,12 +11,15 @@ [app.common.logging :as l] [app.common.pprint :as pp] [app.common.schema :as sm] + [app.common.time :as ct] + [app.config :as cf] [app.db :as db] [app.db.sql :as sql] [app.http.client :as http] [app.main :as-alias main] [app.setup :as-alias setup] [app.tokens :as tokens] + [app.util.cache :as cache] [clojure.data.json :as j] [cuerdas.core :as str] [integrant.core :as ig] @@ -24,6 +27,8 @@ [yetti.response :as-alias yres]) (:import java.net.URI + java.nio.charset.StandardCharsets + java.security.cert.Certificate java.security.cert.CertificateFactory java.security.Signature java.util.Base64)) @@ -80,69 +85,81 @@ (apply str)))) (defn- fetch-certificate - "Fetches the X.509 certificate from the given URL. - Returns an InputStream that must be closed by the caller. + "Fetches and parses the X.509 signing certificate from the given URL. + Raises on network errors or non-200 responses. See: https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html" [cfg cert-url] (let [response (http/req cfg {:uri cert-url :method :get :timeout 10000} {:sync? true :response-type :input-stream})] - (when-not (= 200 (:status response)) - (when-let [body (:body response)] - (.close ^java.io.Closeable body)) - (l/wrn :hint "failed to fetch SNS signing certificate" - :action "sns-cert-fetch-failed" - :status (:status response) - :cert-url cert-url) - (ex/raise :type :internal :code :cert-fetch-failed)) - (:body response))) + (with-open [^java.io.InputStream body (:body response)] + (when-not (= 200 (:status response)) + (ex/raise :type :internal + :code :cert-fetch-failed + :hint "failed to fetch SNS signing certificate" + :status (:status response) + :cert-url cert-url)) + (let [cf (CertificateFactory/getInstance "X.509")] + (.generateCertificate cf body))))) + +(defn- create-cert-cache + "Creates the cache of parsed signing certificates, keyed by URL." + [] + (cache/create :max-size 64 :expire (ct/duration {:hours 24}))) + +(defn- get-certificate + [{:keys [::cert-cache] :as cfg} cert-url] + (cache/get cert-cache cert-url (partial fetch-certificate cfg))) (defn- verify-signature - "Verifies the RSA signature of the message. - See: https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html" - [cfg body] - (let [cert-url (get body "SigningCertURL") - signature (get body "Signature") - sig-version (get body "SignatureVersion") - algorithm (case sig-version - "1" "SHA1withRSA" - "2" "SHA256withRSA" - nil)] - (when-not algorithm - (throw (ex-info "Unsupported SNS signature version" - {:type :validation :version sig-version}))) - (when (and cert-url signature) - (try - (let [string-sign (build-string-to-sign body)] - (with-open [cert-stream (fetch-certificate cfg cert-url)] - (let [cf (CertificateFactory/getInstance "X.509") - cert (.generateCertificate cf cert-stream) - sig (Signature/getInstance algorithm)] - (.initVerify sig (.getPublicKey cert)) - (.update sig (.getBytes string-sign java.nio.charset.StandardCharsets/UTF_8)) - (.verify sig (.decode (Base64/getDecoder) signature))))) - (catch clojure.lang.ExceptionInfo e - (let [data (ex-data e)] - (if (= :validation (:type data)) - (throw e) - (do - (l/wrn :hint "SNS signature verification exception" - :action "sns-signature-verification-exception" - :cause e) - false)))) - (catch Exception e - (l/wrn :hint "SNS signature verification exception" - :action "sns-signature-verification-exception" - :cause e) - false))))) - -(defn- verify-sns-message! - "Verifies the AWS SNS message signature and URL validity. - Throws if verification fails. + "Verifies the RSA signature of the message. Returns false when the + signature does not match or is malformed. Raises when the signing + certificate cannot be obtained. See: https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html" [cfg body] (let [cert-url (get body "SigningCertURL") + signature (get body "Signature") + sig-version (get body "SignatureVersion") + algorithm (case sig-version + "1" "SHA1withRSA" + "2" "SHA256withRSA" + nil)] + (when-not algorithm + (ex/raise :type :validation + :code :unsupported-signature-version + :version sig-version)) + (if (string? signature) + (let [^Certificate cert (get-certificate cfg cert-url)] + (try + (let [sig (Signature/getInstance ^String algorithm)] + (.initVerify sig (.getPublicKey cert)) + (.update sig (.getBytes ^String (build-string-to-sign body) StandardCharsets/UTF_8)) + (.verify sig (.decode (Base64/getDecoder) ^String signature))) + (catch Exception e + (l/wrn :hint "SNS signature verification exception" + :action "sns-signature-verification-exception" + :cause e) + false))) + false))) + +(defn- verify-sns-message! + "Verifies that the message comes from an allowed topic, that its + URLs point to SNS and that its signature is valid. Raises a + :validation or :authentication error otherwise. + See: https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html" + [cfg body] + (let [cert-url (get body "SigningCertURL") subscribe-url (get body "SubscribeURL") - mtype (get body "Type")] + topic-arn (get body "TopicArn") + mtype (get body "Type")] + + (when-not (contains? (cf/get :aws-sns-topic-arns) topic-arn) + (l/wrn :hint "SNS topic not allowed (check PENPOT_AWS_SNS_TOPIC_ARNS)" + :action "sns-topic-not-allowed" + :message-type mtype + :topic-arn topic-arn) + (ex/raise :type :validation + :code :topic-not-allowed + :hint "SNS topic not allowed")) (when-not (valid-sns-url? cert-url) (l/wrn :hint "SNS certificate URL not from amazonaws.com" @@ -167,7 +184,7 @@ (l/wrn :hint "SNS signature verification failed" :action "sns-signature-verification-failed" :message-type mtype - :topic-arn (get body "TopicArn") + :topic-arn topic-arn :signing-cert-url cert-url) (ex/raise :type :authentication :code :invalid-signature @@ -181,14 +198,18 @@ (defmethod ig/init-key ::routes [_ cfg] - (letfn [(handler [request] - (let [data (-> request yreq/body slurp) - result (handle-request cfg data)] - {::yres/status (or (:status result) 200)}))] - ["/sns" {:handler handler - :allowed-methods #{:post}}])) + (let [cfg (assoc cfg ::cert-cache (create-cert-cache))] + (letfn [(handler [request] + (let [data (-> request yreq/body slurp) + result (handle-request cfg data)] + {::yres/status (or (:status result) 200)}))] + ["/sns" {:handler handler + :allowed-methods #{:post}}]))) (defn handle-request + "Handles an SNS message. Returns a map with the HTTP :status: 400 for + messages that fail validation (SNS does not retry them) and 500 for + unexpected or transient errors (SNS retries them)." [cfg data] (try (let [body (parse-json data) @@ -222,8 +243,8 @@ :report (pr-str body)) {:status 400}))) - (catch clojure.lang.ExceptionInfo e - (let [data (ex-data e)] + (catch Throwable cause + (let [data (ex-data cause)] (if (#{:validation :authentication} (:type data)) (do (l/wrn :hint "SNS message validation failed" @@ -232,13 +253,8 @@ {:status 400}) (do (l/error :hint "unexpected exception on awsns" - :cause e) - {:status 500})))) - - (catch Throwable cause - (l/error :hint "unexpected exception on awsns" - :cause cause) - {:status 500}))) + :cause cause) + {:status 500})))))) (defn- parse-bounce [data] diff --git a/backend/test/backend_tests/bounce_handling_test.clj b/backend/test/backend_tests/bounce_handling_test.clj index 534a5ed771..b86c851227 100644 --- a/backend/test/backend_tests/bounce_handling_test.clj +++ b/backend/test/backend_tests/bounce_handling_test.clj @@ -6,16 +6,31 @@ (ns backend-tests.bounce-handling-test (:require + [app.common.exceptions :as ex] [app.common.time :as ct] + [app.config :as cf] [app.db :as db] [app.email :as email] [app.http.awsns :as awsns] + [app.http.client :as http] [app.tokens :as tokens] [backend-tests.helpers :as th] [clojure.data.json :as j] + [clojure.java.io :as io] [clojure.pprint :refer [pprint]] + [clojure.string :as str] [clojure.test :as t] - [mockery.core :refer [with-mocks]])) + [mockery.core :refer [with-mocks]]) + (:import + java.io.ByteArrayInputStream + java.nio.charset.StandardCharsets + java.security.cert.Certificate + java.security.cert.CertificateFactory + java.security.KeyFactory + java.security.KeyPairGenerator + java.security.Signature + java.security.spec.PKCS8EncodedKeySpec + java.util.Base64)) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -308,143 +323,156 @@ (t/is (true? (#'awsns/valid-sns-url? "https://sns.us-east-1.amazonaws.com/cert.pem"))) (t/is (true? (#'awsns/valid-sns-url? "https://sns.ap-southeast-1.amazonaws.com/cert.pem")))) -;; Helper to load test certificate and private key from resources ;; See: https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html (defn- load-test-cert-and-key "Loads the test certificate and private key from test resources." [] - (let [cert-pem (slurp (clojure.java.io/resource "sns-test-cert.pem")) - key-pem (slurp (clojure.java.io/resource "sns-test-key.pem")) - ;; Parse certificate - cert-bytes (.getBytes (-> cert-pem - (clojure.string/replace "-----BEGIN CERTIFICATE-----" "") - (clojure.string/replace "-----END CERTIFICATE-----" "") - (clojure.string/replace #"\s+" "")) - java.nio.charset.StandardCharsets/UTF_8) - cert-input (java.io.ByteArrayInputStream. (.decode (java.util.Base64/getDecoder) cert-bytes)) - cf (java.security.cert.CertificateFactory/getInstance "X.509") - cert (.generateCertificate cf cert-input) - ;; Parse private key - key-bytes (.getBytes (-> key-pem - (clojure.string/replace "-----BEGIN PRIVATE KEY-----" "") - (clojure.string/replace "-----END PRIVATE KEY-----" "") - (clojure.string/replace #"\s+" "")) - java.nio.charset.StandardCharsets/UTF_8) - key-spec (java.security.spec.PKCS8EncodedKeySpec. (.decode (java.util.Base64/getDecoder) key-bytes)) - kf (java.security.KeyFactory/getInstance "RSA") - private-key (.generatePrivate kf key-spec)] + (let [cert-pem (slurp (io/resource "sns-test-cert.pem")) + key-pem (slurp (io/resource "sns-test-key.pem")) + cf (CertificateFactory/getInstance "X.509") + cert (.generateCertificate cf (ByteArrayInputStream. (.getBytes ^String cert-pem StandardCharsets/UTF_8))) + key-bytes (-> key-pem + (str/replace "-----BEGIN PRIVATE KEY-----" "") + (str/replace "-----END PRIVATE KEY-----" "") + (str/replace #"\s+" "")) + key-spec (PKCS8EncodedKeySpec. (.decode (Base64/getDecoder) ^String key-bytes)) + private-key (.generatePrivate (KeyFactory/getInstance "RSA") key-spec)] {:cert cert - :cert-bytes (.getEncoded cert) - :private-key private-key - :public-key (.getPublicKey cert)})) + :private-key private-key})) + +(def ^:private topic-arn "arn:aws:sns:eu-central-1:123:penpot-bounces") +(def ^:private cert-url "https://sns.eu-central-1.amazonaws.com/cert.pem") + +(defn- sign-message + "Adds a Signature to the message computed with the given private key." + [private-key msg] + (let [algorithm (if (= "2" (get msg "SignatureVersion")) "SHA256withRSA" "SHA1withRSA") + sig (Signature/getInstance algorithm)] + (.initSign sig private-key) + (.update sig (.getBytes ^String (#'awsns/build-string-to-sign msg) StandardCharsets/UTF_8)) + (assoc msg "Signature" (.encodeToString (Base64/getEncoder) (.sign sig))))) + +(defn- notification + [message & {:as attrs}] + (merge {"Type" "Notification" + "MessageId" "msg-123" + "TopicArn" topic-arn + "Message" message + "Timestamp" "2021-02-04T14:41:37.020Z" + "SigningCertURL" cert-url + "SignatureVersion" "1"} + attrs)) + +(defn- subscription-confirmation + [& {:as attrs}] + (merge {"Type" "SubscriptionConfirmation" + "MessageId" "msg-456" + "TopicArn" topic-arn + "Message" "You have chosen to subscribe" + "Timestamp" "2021-02-04T14:41:37.020Z" + "Token" "test-token-123" + "SubscribeURL" "https://sns.eu-central-1.amazonaws.com/?Action=ConfirmSubscription" + "SigningCertURL" cert-url + "SignatureVersion" "1"} + attrs)) + +(defn- system-with-cert-cache + [] + (assoc th/*system* ::awsns/cert-cache (#'awsns/create-cert-cache))) + +(defn- handle-sns + "Runs handle-request with the test topic allowed and the test + certificate served for any SigningCertURL. Returns the result plus + the number of certificate fetches and the outbound HTTP requests." + [msg & {:keys [allowed-topics fetch-fn] + :or {allowed-topics #{topic-arn}}}] + (let [{:keys [cert]} (load-test-cert-and-key) + fetches (atom 0) + requests (atom []) + fetch-fn (or fetch-fn (fn [_ _] cert))] + (binding [cf/config (assoc cf/config :aws-sns-topic-arns allowed-topics)] + (with-redefs [awsns/fetch-certificate (fn [cfg url] + (swap! fetches inc) + (fetch-fn cfg url)) + http/req (fn [_ request & _] + (swap! requests conj request) + {:status 200})] + (let [result (#'awsns/handle-request (system-with-cert-cache) (j/write-str msg))] + (assoc result :fetches @fetches :requests @requests)))))) + +(defn- global-reports + [] + (db/query (:app.db/pool th/*system*) :global-complaint-report :all)) (t/deftest test-verify-signature-end-to-end-v1 - (let [{:keys [cert-bytes private-key]} (load-test-cert-and-key) - - msg {"Type" "Notification" - "MessageId" "test-msg-1" - "TopicArn" "arn:aws:sns:us-east-1:123:topic" - "Message" "test message" - "Timestamp" "2021-02-04T14:41:37.020Z" - "SigningCertURL" "https://sns.us-east-1.amazonaws.com/cert.pem" - "SignatureVersion" "1"} - - string-to-sign (#'awsns/build-string-to-sign msg) - sig (java.security.Signature/getInstance "SHA1withRSA") - _ (.initSign sig private-key) - _ (.update sig (.getBytes string-to-sign java.nio.charset.StandardCharsets/UTF_8)) - signature (.encodeToString (java.util.Base64/getEncoder) (.sign sig)) - msg-with-sig (assoc msg "Signature" signature)] - - (with-redefs [awsns/fetch-certificate (fn [_ _] - (java.io.ByteArrayInputStream. cert-bytes))] - (t/is (true? (#'awsns/verify-signature {} msg-with-sig)))))) + (let [{:keys [cert private-key]} (load-test-cert-and-key) + msg (sign-message private-key (notification "test message"))] + (with-redefs [awsns/fetch-certificate (constantly cert)] + (t/is (true? (#'awsns/verify-signature (system-with-cert-cache) msg)))))) (t/deftest test-verify-signature-end-to-end-v2 - (let [{:keys [cert-bytes private-key]} (load-test-cert-and-key) - - msg {"Type" "Notification" - "MessageId" "test-msg-2" - "TopicArn" "arn:aws:sns:us-east-1:123:topic" - "Message" "test message" - "Timestamp" "2021-02-04T14:41:37.020Z" - "SigningCertURL" "https://sns.us-east-1.amazonaws.com/cert.pem" - "SignatureVersion" "2"} - - string-to-sign (#'awsns/build-string-to-sign msg) - sig (java.security.Signature/getInstance "SHA256withRSA") - _ (.initSign sig private-key) - _ (.update sig (.getBytes string-to-sign java.nio.charset.StandardCharsets/UTF_8)) - signature (.encodeToString (java.util.Base64/getEncoder) (.sign sig)) - msg-with-sig (assoc msg "Signature" signature)] - - (with-redefs [awsns/fetch-certificate (fn [_ _] - (java.io.ByteArrayInputStream. cert-bytes))] - (t/is (true? (#'awsns/verify-signature {} msg-with-sig)))))) + (let [{:keys [cert private-key]} (load-test-cert-and-key) + msg (sign-message private-key (notification "test message" "SignatureVersion" "2"))] + (with-redefs [awsns/fetch-certificate (constantly cert)] + (t/is (true? (#'awsns/verify-signature (system-with-cert-cache) msg)))))) (t/deftest test-verify-signature-end-to-end-subscription-confirmation - (let [{:keys [cert-bytes private-key]} (load-test-cert-and-key) - - msg {"Type" "SubscriptionConfirmation" - "MessageId" "test-msg-3" - "TopicArn" "arn:aws:sns:us-east-1:123:topic" - "Message" "You have chosen to subscribe" - "Timestamp" "2021-02-04T14:41:37.020Z" - "Token" "test-token-123" - "SubscribeURL" "https://sns.us-east-1.amazonaws.com/confirm" - "SigningCertURL" "https://sns.us-east-1.amazonaws.com/cert.pem" - "SignatureVersion" "1"} - - string-to-sign (#'awsns/build-string-to-sign msg) - sig (java.security.Signature/getInstance "SHA1withRSA") - _ (.initSign sig private-key) - _ (.update sig (.getBytes string-to-sign java.nio.charset.StandardCharsets/UTF_8)) - signature (.encodeToString (java.util.Base64/getEncoder) (.sign sig)) - msg-with-sig (assoc msg "Signature" signature)] - - (with-redefs [awsns/fetch-certificate (fn [_ _] - (java.io.ByteArrayInputStream. cert-bytes))] - (t/is (true? (#'awsns/verify-signature {} msg-with-sig)))))) + (let [{:keys [cert private-key]} (load-test-cert-and-key) + msg (sign-message private-key (subscription-confirmation))] + (with-redefs [awsns/fetch-certificate (constantly cert)] + (t/is (true? (#'awsns/verify-signature (system-with-cert-cache) msg)))))) (t/deftest test-verify-signature-rejects-wrong-key - (let [{:keys [cert-bytes]} (load-test-cert-and-key) - ;; Generate a different keypair for signing - keypair-gen (java.security.KeyPairGenerator/getInstance "RSA") - _ (.initialize keypair-gen 2048) - kp (.generateKeyPair keypair-gen) - wrong-private-key (.getPrivate kp) + (let [{:keys [cert]} (load-test-cert-and-key) + keypair-gen (doto (KeyPairGenerator/getInstance "RSA") (.initialize 2048)) + wrong-key (.getPrivate (.generateKeyPair keypair-gen)) + msg (sign-message wrong-key (notification "test message"))] + (with-redefs [awsns/fetch-certificate (constantly cert)] + (t/is (false? (#'awsns/verify-signature (system-with-cert-cache) msg)))))) - msg {"Type" "Notification" - "MessageId" "test-msg-4" - "TopicArn" "arn:aws:sns:us-east-1:123:topic" - "Message" "test message" - "Timestamp" "2021-02-04T14:41:37.020Z" - "SigningCertURL" "https://sns.us-east-1.amazonaws.com/cert.pem" - "SignatureVersion" "1"} - - string-to-sign (#'awsns/build-string-to-sign msg) - sig (java.security.Signature/getInstance "SHA1withRSA") - _ (.initSign sig wrong-private-key) - _ (.update sig (.getBytes string-to-sign java.nio.charset.StandardCharsets/UTF_8)) - signature (.encodeToString (java.util.Base64/getEncoder) (.sign sig)) - msg-with-sig (assoc msg "Signature" signature)] - - (with-redefs [awsns/fetch-certificate (fn [_ _] - (java.io.ByteArrayInputStream. cert-bytes))] - (t/is (false? (#'awsns/verify-signature {} msg-with-sig)))))) +(t/deftest test-verify-signature-rejects-malformed-or-missing-signature + (let [{:keys [cert]} (load-test-cert-and-key)] + (with-redefs [awsns/fetch-certificate (constantly cert)] + (t/is (false? (#'awsns/verify-signature (system-with-cert-cache) + (notification "m" "Signature" "not base64 !!")))) + (t/is (false? (#'awsns/verify-signature (system-with-cert-cache) + (notification "m"))))))) (t/deftest test-verify-signature-rejects-unsupported-version - (let [msg {"Type" "Notification" - "MessageId" "test-msg-3" - "TopicArn" "arn:aws:sns:us-east-1:123:topic" - "Message" "test message" - "Timestamp" "2021-02-04T14:41:37.020Z" - "SigningCertURL" "https://sns.us-east-1.amazonaws.com/cert.pem" - "SignatureVersion" "3" - "Signature" "fake=="}] + (t/is (thrown? clojure.lang.ExceptionInfo + (#'awsns/verify-signature (system-with-cert-cache) + (notification "m" + "SignatureVersion" "3" + "Signature" "fake=="))))) - (t/is (thrown? clojure.lang.ExceptionInfo - (#'awsns/verify-signature {} msg))))) +(t/deftest test-verify-signature-caches-certificate + (let [{:keys [cert private-key]} (load-test-cert-and-key) + fetches (atom 0) + system (system-with-cert-cache) + msg (sign-message private-key (notification "test message"))] + (with-redefs [awsns/fetch-certificate (fn [_ _] (swap! fetches inc) cert)] + (t/is (true? (#'awsns/verify-signature system msg))) + (t/is (true? (#'awsns/verify-signature system msg))) + (t/is (= 1 @fetches))))) + +(t/deftest test-fetch-certificate-parses-certificate + (let [pem (slurp (io/resource "sns-test-cert.pem"))] + (with-redefs [http/req (fn [& _] + {:status 200 + :body (ByteArrayInputStream. (.getBytes ^String pem StandardCharsets/UTF_8))})] + (t/is (instance? Certificate (#'awsns/fetch-certificate th/*system* cert-url)))))) + +(t/deftest test-fetch-certificate-raises-and-closes-body-on-error-status + (let [closed? (atom false) + body (proxy [ByteArrayInputStream] [(byte-array 0)] + (close [] (reset! closed? true)))] + (with-redefs [http/req (fn [& _] {:status 503 :body body})] + (let [error (try + (#'awsns/fetch-certificate th/*system* cert-url) + nil + (catch clojure.lang.ExceptionInfo e e))] + (t/is (= :cert-fetch-failed (:code (ex-data error)))) + (t/is (true? @closed?)))))) (t/deftest test-build-string-to-sign-v1-notification (let [msg {"Type" "Notification" @@ -506,74 +534,90 @@ (t/is (.contains result "Token")) (t/is (.contains result "test-token-123")))) -(t/deftest test-handle-request-returns-4xx-for-invalid-signature - (let [{:keys [cert-bytes]} (load-test-cert-and-key) - body (j/write-str - {"Type" "Notification" - "MessageId" "msg-123" - "TopicArn" "arn:aws:sns:eu-central-1:123:topic" - "Message" "{\"test\":\"data\"}" - "Timestamp" "2021-02-04T14:41:37.020Z" - "SigningCertURL" "https://sns.eu-central-1.amazonaws.com/cert.pem" - "SignatureVersion" "1" - "Signature" "invalid-signature=="}) - result (with-redefs [awsns/fetch-certificate (fn [_ _] - (java.io.ByteArrayInputStream. cert-bytes))] - (#'awsns/handle-request th/*system* body))] - (t/is (= 400 (:status result))))) +(t/deftest test-handle-request-processes-valid-bounce + (let [profile (th/create-profile* 1) + {:keys [private-key]} (load-test-cert-and-key) + token (tokens/generate th/*system* {:iss :profile-identity + :profile-id (:id profile)}) + msg (->> (notification (j/write-str (bounce-report {:token token}))) + (sign-message private-key)) + result (handle-sns msg) + rows (global-reports)] + (t/is (= 200 (:status result))) + (t/is (= 1 (count rows))) + (t/is (= "user@example.com" (:email (first rows)))))) -(t/deftest test-handle-request-returns-4xx-for-invalid-url - (let [body (j/write-str - {"Type" "Notification" - "MessageId" "msg-123" - "TopicArn" "arn:aws:sns:eu-central-1:123:topic" - "Message" "{\"test\":\"data\"}" - "Timestamp" "2021-02-04T14:41:37.020Z" - "SigningCertURL" "https://evil.com/cert.pem" - "SignatureVersion" "1" - "Signature" "fake-signature=="}) - result (#'awsns/handle-request th/*system* body)] - (t/is (= 400 (:status result))))) +(t/deftest test-handle-request-rejects-signed-message-from-other-topic + (let [profile (th/create-profile* 1) + {:keys [private-key]} (load-test-cert-and-key) + token (tokens/generate th/*system* {:iss :profile-identity + :profile-id (:id profile)}) + msg (->> (notification (j/write-str (bounce-report {:token token})) + "TopicArn" "arn:aws:sns:eu-central-1:999:attacker") + (sign-message private-key)) + result (handle-sns msg)] + (t/is (= 400 (:status result))) + (t/is (zero? (:fetches result))) + (t/is (empty? (global-reports))))) -(t/deftest test-handle-request-rejects-invalid-signing-cert-url - (let [pool (:app.db/pool th/*system*) - profile (th/create-profile* 1) - token (tokens/generate th/*system* - {:iss :profile-identity - :profile-id (:id profile)}) - body (j/write-str - {"Type" "Notification" - "MessageId" "msg-123" - "TopicArn" "arn:aws:sns:eu-central-1:123:topic" - "Message" (j/write-str {"notificationType" "Bounce" - "bounce" {"bounceType" "Permanent" - "bounceSubType" "General" - "bouncedRecipients" [{"emailAddress" "victim@example.com"}] - "timestamp" "2021-02-04T14:41:38.000Z"} - "mail" {"source" "no-reply@penpot.app" - "destination" ["victim@example.com"] - "timestamp" "2021-02-04T14:41:37.020Z" - "headers" [{"name" "X-Penpot-Data" "value" token}]}}) - "Timestamp" "2021-02-04T14:41:37.020Z" - "SigningCertURL" "https://evil.com/cert.pem" - "SignatureVersion" "1" - "Signature" "fake-signature=="})] - (#'awsns/handle-request th/*system* body) - (let [reports (db/query pool :global-complaint-report :all)] - (t/is (empty? reports))))) +(t/deftest test-handle-request-rejects-all-topics-when-none-configured + (let [{:keys [private-key]} (load-test-cert-and-key) + msg (sign-message private-key (notification "{}")) + result (handle-sns msg :allowed-topics nil)] + (t/is (= 400 (:status result))) + (t/is (zero? (:fetches result))))) + +(t/deftest test-handle-request-confirms-subscription-from-allowed-topic + (let [{:keys [private-key]} (load-test-cert-and-key) + msg (sign-message private-key (subscription-confirmation)) + result (handle-sns msg)] + (t/is (= 200 (:status result))) + (t/is (= [(get msg "SubscribeURL")] (mapv :uri (:requests result)))))) + +(t/deftest test-handle-request-ignores-subscription-from-other-topic + (let [{:keys [private-key]} (load-test-cert-and-key) + msg (->> (subscription-confirmation "TopicArn" "arn:aws:sns:eu-central-1:999:attacker") + (sign-message private-key)) + result (handle-sns msg)] + (t/is (= 400 (:status result))) + (t/is (empty? (:requests result))))) (t/deftest test-handle-request-rejects-invalid-subscribe-url - (let [pool (:app.db/pool th/*system*) - body (j/write-str - {"Type" "SubscriptionConfirmation" - "MessageId" "msg-456" - "TopicArn" "arn:aws:sns:eu-central-1:123:topic" - "Message" "You have chosen to subscribe" - "Timestamp" "2021-02-04T14:41:37.020Z" - "SigningCertURL" "https://sns.eu-central-1.amazonaws.com/cert.pem" - "SignatureVersion" "1" - "Signature" "fake-signature==" - "SubscribeURL" "http://attacker.com/confirm"})] - (#'awsns/handle-request th/*system* body) - (let [reports (db/query pool :global-complaint-report :all)] - (t/is (empty? reports))))) + (let [{:keys [private-key]} (load-test-cert-and-key) + msg (->> (subscription-confirmation "SubscribeURL" "http://attacker.com/confirm") + (sign-message private-key)) + result (handle-sns msg)] + (t/is (= 400 (:status result))) + (t/is (empty? (:requests result))))) + +(t/deftest test-handle-request-returns-4xx-for-invalid-signature + (let [result (handle-sns (notification "{\"test\":\"data\"}" "Signature" "invalid-signature=="))] + (t/is (= 400 (:status result))) + (t/is (= 1 (:fetches result))))) + +(t/deftest test-handle-request-rejects-invalid-signing-cert-url + (let [profile (th/create-profile* 1) + token (tokens/generate th/*system* {:iss :profile-identity + :profile-id (:id profile)}) + msg (notification (j/write-str (bounce-report {:token token :email "victim@example.com"})) + "SigningCertURL" "https://evil.com/cert.pem" + "Signature" "fake-signature==") + result (handle-sns msg)] + (t/is (= 400 (:status result))) + (t/is (zero? (:fetches result))) + (t/is (empty? (global-reports))))) + +(t/deftest test-handle-request-returns-4xx-for-missing-message + (let [{:keys [private-key]} (load-test-cert-and-key) + msg (sign-message private-key (dissoc (notification "x") "Message")) + result (handle-sns msg)] + (t/is (= 400 (:status result))))) + +(t/deftest test-handle-request-returns-5xx-when-certificate-fetch-fails + (let [{:keys [private-key]} (load-test-cert-and-key) + msg (sign-message private-key (notification "{}"))] + (t/is (= 500 (:status (handle-sns msg :fetch-fn (fn [_ _] + (ex/raise :type :internal + :code :cert-fetch-failed)))))) + (t/is (= 500 (:status (handle-sns msg :fetch-fn (fn [_ _] + (throw (java.net.http.HttpTimeoutException. "timeout")))))))))