mirror of
https://github.com/penpot/penpot.git
synced 2026-09-26 13:56:20 +00:00
🐛 Add topic allow list and cert cache
This commit is contained in:
parent
86ed135fc6
commit
55f5b32aa5
@ -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]
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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")))))))))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user