🐛 Add topic allow list and cert cache

This commit is contained in:
alonso.torres 2026-09-23 15:43:02 +02:00
parent 86ed135fc6
commit 55f5b32aa5
3 changed files with 321 additions and 257 deletions

View File

@ -264,6 +264,10 @@
[:smtp-tls {:optional true} ::sm/boolean] [:smtp-tls {:optional true} ::sm/boolean]
[:smtp-username {:optional true} [:maybe :string]] [: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-host {:optional true} :string]
[:urepl-port {:optional true} ::sm/int] [:urepl-port {:optional true} ::sm/int]
[:prepl-host {:optional true} :string] [:prepl-host {:optional true} :string]

View File

@ -11,12 +11,15 @@
[app.common.logging :as l] [app.common.logging :as l]
[app.common.pprint :as pp] [app.common.pprint :as pp]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.time :as ct]
[app.config :as cf]
[app.db :as db] [app.db :as db]
[app.db.sql :as sql] [app.db.sql :as sql]
[app.http.client :as http] [app.http.client :as http]
[app.main :as-alias main] [app.main :as-alias main]
[app.setup :as-alias setup] [app.setup :as-alias setup]
[app.tokens :as tokens] [app.tokens :as tokens]
[app.util.cache :as cache]
[clojure.data.json :as j] [clojure.data.json :as j]
[cuerdas.core :as str] [cuerdas.core :as str]
[integrant.core :as ig] [integrant.core :as ig]
@ -24,6 +27,8 @@
[yetti.response :as-alias yres]) [yetti.response :as-alias yres])
(:import (:import
java.net.URI java.net.URI
java.nio.charset.StandardCharsets
java.security.cert.Certificate
java.security.cert.CertificateFactory java.security.cert.CertificateFactory
java.security.Signature java.security.Signature
java.util.Base64)) java.util.Base64))
@ -80,69 +85,81 @@
(apply str)))) (apply str))))
(defn- fetch-certificate (defn- fetch-certificate
"Fetches the X.509 certificate from the given URL. "Fetches and parses the X.509 signing certificate from the given URL.
Returns an InputStream that must be closed by the caller. Raises on network errors or non-200 responses.
See: https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html" See: https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html"
[cfg cert-url] [cfg cert-url]
(let [response (http/req cfg {:uri cert-url :method :get :timeout 10000} (let [response (http/req cfg {:uri cert-url :method :get :timeout 10000}
{:sync? true :response-type :input-stream})] {:sync? true :response-type :input-stream})]
(when-not (= 200 (:status response)) (with-open [^java.io.InputStream body (:body response)]
(when-let [body (:body response)] (when-not (= 200 (:status response))
(.close ^java.io.Closeable body)) (ex/raise :type :internal
(l/wrn :hint "failed to fetch SNS signing certificate" :code :cert-fetch-failed
:action "sns-cert-fetch-failed" :hint "failed to fetch SNS signing certificate"
:status (:status response) :status (:status response)
:cert-url cert-url) :cert-url cert-url))
(ex/raise :type :internal :code :cert-fetch-failed)) (let [cf (CertificateFactory/getInstance "X.509")]
(:body response))) (.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 (defn- verify-signature
"Verifies the RSA signature of the message. "Verifies the RSA signature of the message. Returns false when the
See: https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html" signature does not match or is malformed. Raises when the signing
[cfg body] certificate cannot be obtained.
(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.
See: https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html" See: https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html"
[cfg body] [cfg body]
(let [cert-url (get body "SigningCertURL") (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") 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) (when-not (valid-sns-url? cert-url)
(l/wrn :hint "SNS certificate URL not from amazonaws.com" (l/wrn :hint "SNS certificate URL not from amazonaws.com"
@ -167,7 +184,7 @@
(l/wrn :hint "SNS signature verification failed" (l/wrn :hint "SNS signature verification failed"
:action "sns-signature-verification-failed" :action "sns-signature-verification-failed"
:message-type mtype :message-type mtype
:topic-arn (get body "TopicArn") :topic-arn topic-arn
:signing-cert-url cert-url) :signing-cert-url cert-url)
(ex/raise :type :authentication (ex/raise :type :authentication
:code :invalid-signature :code :invalid-signature
@ -181,14 +198,18 @@
(defmethod ig/init-key ::routes (defmethod ig/init-key ::routes
[_ cfg] [_ cfg]
(letfn [(handler [request] (let [cfg (assoc cfg ::cert-cache (create-cert-cache))]
(let [data (-> request yreq/body slurp) (letfn [(handler [request]
result (handle-request cfg data)] (let [data (-> request yreq/body slurp)
{::yres/status (or (:status result) 200)}))] result (handle-request cfg data)]
["/sns" {:handler handler {::yres/status (or (:status result) 200)}))]
:allowed-methods #{:post}}])) ["/sns" {:handler handler
:allowed-methods #{:post}}])))
(defn handle-request (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] [cfg data]
(try (try
(let [body (parse-json data) (let [body (parse-json data)
@ -222,8 +243,8 @@
:report (pr-str body)) :report (pr-str body))
{:status 400}))) {:status 400})))
(catch clojure.lang.ExceptionInfo e (catch Throwable cause
(let [data (ex-data e)] (let [data (ex-data cause)]
(if (#{:validation :authentication} (:type data)) (if (#{:validation :authentication} (:type data))
(do (do
(l/wrn :hint "SNS message validation failed" (l/wrn :hint "SNS message validation failed"
@ -232,13 +253,8 @@
{:status 400}) {:status 400})
(do (do
(l/error :hint "unexpected exception on awsns" (l/error :hint "unexpected exception on awsns"
:cause e) :cause cause)
{:status 500})))) {:status 500}))))))
(catch Throwable cause
(l/error :hint "unexpected exception on awsns"
:cause cause)
{:status 500})))
(defn- parse-bounce (defn- parse-bounce
[data] [data]

View File

@ -6,16 +6,31 @@
(ns backend-tests.bounce-handling-test (ns backend-tests.bounce-handling-test
(:require (:require
[app.common.exceptions :as ex]
[app.common.time :as ct] [app.common.time :as ct]
[app.config :as cf]
[app.db :as db] [app.db :as db]
[app.email :as email] [app.email :as email]
[app.http.awsns :as awsns] [app.http.awsns :as awsns]
[app.http.client :as http]
[app.tokens :as tokens] [app.tokens :as tokens]
[backend-tests.helpers :as th] [backend-tests.helpers :as th]
[clojure.data.json :as j] [clojure.data.json :as j]
[clojure.java.io :as io]
[clojure.pprint :refer [pprint]] [clojure.pprint :refer [pprint]]
[clojure.string :as str]
[clojure.test :as t] [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 :once th/state-init)
(t/use-fixtures :each th/database-reset) (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.us-east-1.amazonaws.com/cert.pem")))
(t/is (true? (#'awsns/valid-sns-url? "https://sns.ap-southeast-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 ;; See: https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html
(defn- load-test-cert-and-key (defn- load-test-cert-and-key
"Loads the test certificate and private key from test resources." "Loads the test certificate and private key from test resources."
[] []
(let [cert-pem (slurp (clojure.java.io/resource "sns-test-cert.pem")) (let [cert-pem (slurp (io/resource "sns-test-cert.pem"))
key-pem (slurp (clojure.java.io/resource "sns-test-key.pem")) key-pem (slurp (io/resource "sns-test-key.pem"))
;; Parse certificate cf (CertificateFactory/getInstance "X.509")
cert-bytes (.getBytes (-> cert-pem cert (.generateCertificate cf (ByteArrayInputStream. (.getBytes ^String cert-pem StandardCharsets/UTF_8)))
(clojure.string/replace "-----BEGIN CERTIFICATE-----" "") key-bytes (-> key-pem
(clojure.string/replace "-----END CERTIFICATE-----" "") (str/replace "-----BEGIN PRIVATE KEY-----" "")
(clojure.string/replace #"\s+" "")) (str/replace "-----END PRIVATE KEY-----" "")
java.nio.charset.StandardCharsets/UTF_8) (str/replace #"\s+" ""))
cert-input (java.io.ByteArrayInputStream. (.decode (java.util.Base64/getDecoder) cert-bytes)) key-spec (PKCS8EncodedKeySpec. (.decode (Base64/getDecoder) ^String key-bytes))
cf (java.security.cert.CertificateFactory/getInstance "X.509") private-key (.generatePrivate (KeyFactory/getInstance "RSA") key-spec)]
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)]
{:cert cert {:cert cert
:cert-bytes (.getEncoded cert) :private-key private-key}))
:private-key private-key
:public-key (.getPublicKey cert)})) (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 (t/deftest test-verify-signature-end-to-end-v1
(let [{:keys [cert-bytes private-key]} (load-test-cert-and-key) (let [{:keys [cert private-key]} (load-test-cert-and-key)
msg (sign-message private-key (notification "test message"))]
msg {"Type" "Notification" (with-redefs [awsns/fetch-certificate (constantly cert)]
"MessageId" "test-msg-1" (t/is (true? (#'awsns/verify-signature (system-with-cert-cache) msg))))))
"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))))))
(t/deftest test-verify-signature-end-to-end-v2 (t/deftest test-verify-signature-end-to-end-v2
(let [{:keys [cert-bytes private-key]} (load-test-cert-and-key) (let [{:keys [cert private-key]} (load-test-cert-and-key)
msg (sign-message private-key (notification "test message" "SignatureVersion" "2"))]
msg {"Type" "Notification" (with-redefs [awsns/fetch-certificate (constantly cert)]
"MessageId" "test-msg-2" (t/is (true? (#'awsns/verify-signature (system-with-cert-cache) msg))))))
"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))))))
(t/deftest test-verify-signature-end-to-end-subscription-confirmation (t/deftest test-verify-signature-end-to-end-subscription-confirmation
(let [{:keys [cert-bytes private-key]} (load-test-cert-and-key) (let [{:keys [cert private-key]} (load-test-cert-and-key)
msg (sign-message private-key (subscription-confirmation))]
msg {"Type" "SubscriptionConfirmation" (with-redefs [awsns/fetch-certificate (constantly cert)]
"MessageId" "test-msg-3" (t/is (true? (#'awsns/verify-signature (system-with-cert-cache) msg))))))
"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))))))
(t/deftest test-verify-signature-rejects-wrong-key (t/deftest test-verify-signature-rejects-wrong-key
(let [{:keys [cert-bytes]} (load-test-cert-and-key) (let [{:keys [cert]} (load-test-cert-and-key)
;; Generate a different keypair for signing keypair-gen (doto (KeyPairGenerator/getInstance "RSA") (.initialize 2048))
keypair-gen (java.security.KeyPairGenerator/getInstance "RSA") wrong-key (.getPrivate (.generateKeyPair keypair-gen))
_ (.initialize keypair-gen 2048) msg (sign-message wrong-key (notification "test message"))]
kp (.generateKeyPair keypair-gen) (with-redefs [awsns/fetch-certificate (constantly cert)]
wrong-private-key (.getPrivate kp) (t/is (false? (#'awsns/verify-signature (system-with-cert-cache) msg))))))
msg {"Type" "Notification" (t/deftest test-verify-signature-rejects-malformed-or-missing-signature
"MessageId" "test-msg-4" (let [{:keys [cert]} (load-test-cert-and-key)]
"TopicArn" "arn:aws:sns:us-east-1:123:topic" (with-redefs [awsns/fetch-certificate (constantly cert)]
"Message" "test message" (t/is (false? (#'awsns/verify-signature (system-with-cert-cache)
"Timestamp" "2021-02-04T14:41:37.020Z" (notification "m" "Signature" "not base64 !!"))))
"SigningCertURL" "https://sns.us-east-1.amazonaws.com/cert.pem" (t/is (false? (#'awsns/verify-signature (system-with-cert-cache)
"SignatureVersion" "1"} (notification "m")))))))
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-unsupported-version (t/deftest test-verify-signature-rejects-unsupported-version
(let [msg {"Type" "Notification" (t/is (thrown? clojure.lang.ExceptionInfo
"MessageId" "test-msg-3" (#'awsns/verify-signature (system-with-cert-cache)
"TopicArn" "arn:aws:sns:us-east-1:123:topic" (notification "m"
"Message" "test message" "SignatureVersion" "3"
"Timestamp" "2021-02-04T14:41:37.020Z" "Signature" "fake==")))))
"SigningCertURL" "https://sns.us-east-1.amazonaws.com/cert.pem"
"SignatureVersion" "3"
"Signature" "fake=="}]
(t/is (thrown? clojure.lang.ExceptionInfo (t/deftest test-verify-signature-caches-certificate
(#'awsns/verify-signature {} msg))))) (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 (t/deftest test-build-string-to-sign-v1-notification
(let [msg {"Type" "Notification" (let [msg {"Type" "Notification"
@ -506,74 +534,90 @@
(t/is (.contains result "Token")) (t/is (.contains result "Token"))
(t/is (.contains result "test-token-123")))) (t/is (.contains result "test-token-123"))))
(t/deftest test-handle-request-returns-4xx-for-invalid-signature (t/deftest test-handle-request-processes-valid-bounce
(let [{:keys [cert-bytes]} (load-test-cert-and-key) (let [profile (th/create-profile* 1)
body (j/write-str {:keys [private-key]} (load-test-cert-and-key)
{"Type" "Notification" token (tokens/generate th/*system* {:iss :profile-identity
"MessageId" "msg-123" :profile-id (:id profile)})
"TopicArn" "arn:aws:sns:eu-central-1:123:topic" msg (->> (notification (j/write-str (bounce-report {:token token})))
"Message" "{\"test\":\"data\"}" (sign-message private-key))
"Timestamp" "2021-02-04T14:41:37.020Z" result (handle-sns msg)
"SigningCertURL" "https://sns.eu-central-1.amazonaws.com/cert.pem" rows (global-reports)]
"SignatureVersion" "1" (t/is (= 200 (:status result)))
"Signature" "invalid-signature=="}) (t/is (= 1 (count rows)))
result (with-redefs [awsns/fetch-certificate (fn [_ _] (t/is (= "user@example.com" (:email (first rows))))))
(java.io.ByteArrayInputStream. cert-bytes))]
(#'awsns/handle-request th/*system* body))]
(t/is (= 400 (:status result)))))
(t/deftest test-handle-request-returns-4xx-for-invalid-url (t/deftest test-handle-request-rejects-signed-message-from-other-topic
(let [body (j/write-str (let [profile (th/create-profile* 1)
{"Type" "Notification" {:keys [private-key]} (load-test-cert-and-key)
"MessageId" "msg-123" token (tokens/generate th/*system* {:iss :profile-identity
"TopicArn" "arn:aws:sns:eu-central-1:123:topic" :profile-id (:id profile)})
"Message" "{\"test\":\"data\"}" msg (->> (notification (j/write-str (bounce-report {:token token}))
"Timestamp" "2021-02-04T14:41:37.020Z" "TopicArn" "arn:aws:sns:eu-central-1:999:attacker")
"SigningCertURL" "https://evil.com/cert.pem" (sign-message private-key))
"SignatureVersion" "1" result (handle-sns msg)]
"Signature" "fake-signature=="}) (t/is (= 400 (:status result)))
result (#'awsns/handle-request th/*system* body)] (t/is (zero? (:fetches result)))
(t/is (= 400 (:status result))))) (t/is (empty? (global-reports)))))
(t/deftest test-handle-request-rejects-invalid-signing-cert-url (t/deftest test-handle-request-rejects-all-topics-when-none-configured
(let [pool (:app.db/pool th/*system*) (let [{:keys [private-key]} (load-test-cert-and-key)
profile (th/create-profile* 1) msg (sign-message private-key (notification "{}"))
token (tokens/generate th/*system* result (handle-sns msg :allowed-topics nil)]
{:iss :profile-identity (t/is (= 400 (:status result)))
:profile-id (:id profile)}) (t/is (zero? (:fetches result)))))
body (j/write-str
{"Type" "Notification" (t/deftest test-handle-request-confirms-subscription-from-allowed-topic
"MessageId" "msg-123" (let [{:keys [private-key]} (load-test-cert-and-key)
"TopicArn" "arn:aws:sns:eu-central-1:123:topic" msg (sign-message private-key (subscription-confirmation))
"Message" (j/write-str {"notificationType" "Bounce" result (handle-sns msg)]
"bounce" {"bounceType" "Permanent" (t/is (= 200 (:status result)))
"bounceSubType" "General" (t/is (= [(get msg "SubscribeURL")] (mapv :uri (:requests result))))))
"bouncedRecipients" [{"emailAddress" "victim@example.com"}]
"timestamp" "2021-02-04T14:41:38.000Z"} (t/deftest test-handle-request-ignores-subscription-from-other-topic
"mail" {"source" "no-reply@penpot.app" (let [{:keys [private-key]} (load-test-cert-and-key)
"destination" ["victim@example.com"] msg (->> (subscription-confirmation "TopicArn" "arn:aws:sns:eu-central-1:999:attacker")
"timestamp" "2021-02-04T14:41:37.020Z" (sign-message private-key))
"headers" [{"name" "X-Penpot-Data" "value" token}]}}) result (handle-sns msg)]
"Timestamp" "2021-02-04T14:41:37.020Z" (t/is (= 400 (:status result)))
"SigningCertURL" "https://evil.com/cert.pem" (t/is (empty? (:requests result)))))
"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-invalid-subscribe-url (t/deftest test-handle-request-rejects-invalid-subscribe-url
(let [pool (:app.db/pool th/*system*) (let [{:keys [private-key]} (load-test-cert-and-key)
body (j/write-str msg (->> (subscription-confirmation "SubscribeURL" "http://attacker.com/confirm")
{"Type" "SubscriptionConfirmation" (sign-message private-key))
"MessageId" "msg-456" result (handle-sns msg)]
"TopicArn" "arn:aws:sns:eu-central-1:123:topic" (t/is (= 400 (:status result)))
"Message" "You have chosen to subscribe" (t/is (empty? (:requests result)))))
"Timestamp" "2021-02-04T14:41:37.020Z"
"SigningCertURL" "https://sns.eu-central-1.amazonaws.com/cert.pem" (t/deftest test-handle-request-returns-4xx-for-invalid-signature
"SignatureVersion" "1" (let [result (handle-sns (notification "{\"test\":\"data\"}" "Signature" "invalid-signature=="))]
"Signature" "fake-signature==" (t/is (= 400 (:status result)))
"SubscribeURL" "http://attacker.com/confirm"})] (t/is (= 1 (:fetches result)))))
(#'awsns/handle-request th/*system* body)
(let [reports (db/query pool :global-complaint-report :all)] (t/deftest test-handle-request-rejects-invalid-signing-cert-url
(t/is (empty? reports))))) (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")))))))))