🐛 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-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]

View File

@ -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]

View File

@ -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")))))))))