@coderize/email 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/sns.js ADDED
@@ -0,0 +1,168 @@
1
+ /*
2
+ Amazon SNS message signatures, for the route that receives SES bounce, complaint and delivery
3
+ notices (or inbound mail) over an SNS HTTPS subscription. Ported from xceptionalheroes-org-mono's
4
+ lib/snsVerify.js, with the SubscribeURL rule from coderize-dev-mono #410 and gronetworking #207.
5
+
6
+ SNS signs every message it POSTs to an HTTPS subscription, and anyone on the internet can POST to that
7
+ URL, so nothing in a message is trusted until its signature checks out against the certificate SNS
8
+ publishes. The steps are AWS's own ("Verifying the signatures of Amazon SNS messages"):
9
+
10
+ 1. The certificate URL must be HTTPS on exactly sns.<region>.amazonaws.com, with the region taken
11
+ from the message's own TopicArn, and end in .pem, or an attacker could point us at a certificate
12
+ of their own. A pattern like sns.<anything>.amazonaws.com is not enough: sns.s3.amazonaws.com is
13
+ an S3 bucket hostname anyone could have created, and gronetworking and coderize-dev both accepted
14
+ it until November 2026. A subscription message's SubscribeURL is held to the same rule, since the
15
+ API makes a request to it.
16
+ 2. Build the string SNS signed: the message's fields in a fixed order, each "Name\nvalue\n".
17
+ Notifications and subscription messages sign different fields.
18
+ 3. Verify the base64 Signature over that string with the certificate's public key: SHA1withRSA for
19
+ SignatureVersion 1, SHA256withRSA for 2.
20
+
21
+ Certificates are cached by URL, at most 20, until they expire: SNS signs with the same one for months.
22
+ This module has no AWS SDK or Fastify dependency, so a Lambda can use it as well as an API.
23
+ */
24
+ import { createVerify, X509Certificate } from 'node:crypto'
25
+
26
+ const REGION = /^[a-z]{2}(-[a-z]+)+-\d$/
27
+ const FIELDS = {
28
+ Notification: ['Message', 'MessageId', 'Subject', 'Timestamp', 'TopicArn', 'Type'],
29
+ SubscriptionConfirmation: ['Message', 'MessageId', 'SubscribeURL', 'Timestamp', 'Token', 'TopicArn', 'Type'],
30
+ UnsubscribeConfirmation: ['Message', 'MessageId', 'SubscribeURL', 'Timestamp', 'Token', 'TopicArn', 'Type']
31
+ }
32
+ const ALGORITHM = { 1: 'RSA-SHA1', 2: 'RSA-SHA256' }
33
+ const TIMEOUT_MS = 5000
34
+ const MAX_CERTS = 20
35
+
36
+ /**
37
+ * The AWS region in an SNS topic ARN (`arn:aws:sns:<region>:<account>:<name>`), or null.
38
+ * @param {unknown} topicArn
39
+ * @returns {string | null}
40
+ */
41
+ export function topicRegion(topicArn) {
42
+ const region = String(topicArn ?? '').split(':')[3] ?? ''
43
+ return REGION.test(region) ? region : null
44
+ }
45
+
46
+ /**
47
+ * Whether a SigningCertURL (`pem: true`) or SubscribeURL is on the SNS endpoint of `region`: HTTPS, host
48
+ * exactly `sns.<region>.amazonaws.com`, no port, no credentials.
49
+ * @param {unknown} url
50
+ * @param {{ region?: string | null, pem?: boolean }} [options]
51
+ * @returns {boolean}
52
+ */
53
+ export function isSnsUrl(url, { region, pem = false } = {}) {
54
+ if (!region || !REGION.test(region)) return false
55
+ let u
56
+ try {
57
+ u = new URL(String(url))
58
+ } catch {
59
+ return false
60
+ }
61
+ return (
62
+ u.protocol === 'https:' &&
63
+ u.hostname === `sns.${region}.amazonaws.com` &&
64
+ !u.username &&
65
+ !u.password &&
66
+ !u.port &&
67
+ (!pem || u.pathname.endsWith('.pem'))
68
+ )
69
+ }
70
+
71
+ /**
72
+ * The exact string SNS signed for this message, or null for a type it does not sign or a message
73
+ * missing a signed field.
74
+ * @param {Record<string, unknown> | null | undefined} message
75
+ * @returns {string | null}
76
+ */
77
+ export function signingString(message) {
78
+ const fields = FIELDS[/** @type {keyof typeof FIELDS} */ (message?.Type)]
79
+ if (!fields || !message) return null
80
+ let out = ''
81
+ for (const name of fields) {
82
+ const value = message[name]
83
+ // Subject is optional on a notification and left out of the string when absent.
84
+ if (value === undefined || value === null) {
85
+ if (name === 'Subject') continue
86
+ return null
87
+ }
88
+ out += `${name}\n${value}\n`
89
+ }
90
+ return out
91
+ }
92
+
93
+ /** @type {Map<string, { key: import('node:crypto').KeyObject, validTo: number }>} */
94
+ const certs = new Map()
95
+
96
+ /**
97
+ * Fetches the signing certificate and returns its public key, from the cache when it has it. Throws
98
+ * unless the response is a currently valid X.509 certificate. Redirects are refused: the host check is
99
+ * only worth anything if that host is the one that answers.
100
+ * @param {string} url
101
+ * @param {{ fetch?: typeof fetch }} [options] `fetch` is injectable for tests
102
+ * @returns {Promise<import('node:crypto').KeyObject>}
103
+ */
104
+ export async function fetchSigningCert(url, { fetch: fetchImpl = fetch } = {}) {
105
+ const hit = certs.get(url)
106
+ if (hit && hit.validTo > Date.now()) return hit.key
107
+ const res = await fetchImpl(url, { signal: AbortSignal.timeout(TIMEOUT_MS), redirect: 'error' })
108
+ if (!res.ok) throw new Error(`SNS certificate fetch failed: HTTP ${res.status}`)
109
+ const cert = new X509Certificate(await res.text())
110
+ const validTo = new Date(cert.validTo).getTime()
111
+ if (!(validTo > Date.now()) || new Date(cert.validFrom).getTime() > Date.now()) {
112
+ throw new Error('SNS signing certificate is not currently valid')
113
+ }
114
+ const key = cert.publicKey
115
+ if (certs.size >= MAX_CERTS) certs.delete(/** @type {string} */ (certs.keys().next().value))
116
+ certs.set(url, { key, validTo })
117
+ return key
118
+ }
119
+
120
+ /**
121
+ * Whether an SNS message is genuinely from SNS (and, given `topicArn`, from that topic). Never throws:
122
+ * a bad host, a failed certificate fetch, an unknown SignatureVersion and a bad signature are all false.
123
+ *
124
+ * Pin `topicArn` to the topic the app subscribed to. Without it, any SNS topic in any account can deliver
125
+ * a validly signed message to the route.
126
+ * @param {unknown} message the parsed JSON body SNS posted (it arrives as text/plain)
127
+ * @param {object} [options]
128
+ * @param {string | string[]} [options.topicArn] the topic(s) this route accepts
129
+ * @param {(url: string) => Promise<import('node:crypto').KeyObject | string>} [options.fetchCert] replaces the certificate download in tests
130
+ * @returns {Promise<boolean>}
131
+ */
132
+ export async function verifySnsMessage(message, { topicArn, fetchCert = fetchSigningCert } = {}) {
133
+ try {
134
+ const m = /** @type {Record<string, any>} */ (message)
135
+ const algorithm = ALGORITHM[/** @type {1 | 2} */ (m?.SignatureVersion)]
136
+ if (!algorithm || typeof m.Signature !== 'string') return false
137
+ if (topicArn !== undefined && ![topicArn].flat().includes(m.TopicArn)) return false
138
+ const region = topicRegion(m.TopicArn)
139
+ if (!isSnsUrl(m.SigningCertURL, { region, pem: true })) return false
140
+ if (m.Type !== 'Notification' && !isSnsUrl(m.SubscribeURL, { region })) return false
141
+ const signed = signingString(m)
142
+ if (!signed) return false
143
+ const key = await fetchCert(m.SigningCertURL)
144
+ return createVerify(algorithm).update(signed, 'utf8').verify(key, m.Signature, 'base64')
145
+ } catch {
146
+ return false
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Confirms a subscription: GETs the SubscribeURL of a SubscriptionConfirmation that has already passed
152
+ * `verifySnsMessage`. The URL is checked again here (exactly the SNS endpoint of the topic's region), and
153
+ * the request refuses redirects and gives up after 5 seconds. Throws when the URL fails the check;
154
+ * resolves the response status otherwise.
155
+ * @param {Record<string, any>} message
156
+ * @param {{ fetch?: typeof fetch }} [options] `fetch` is injectable for tests
157
+ * @returns {Promise<{ ok: boolean, status: number }>}
158
+ */
159
+ export async function confirmSubscription(message, { fetch: fetchImpl = fetch } = {}) {
160
+ if (message?.Type !== 'SubscriptionConfirmation') {
161
+ throw new Error('confirmSubscription: not a SubscriptionConfirmation message')
162
+ }
163
+ if (!isSnsUrl(message.SubscribeURL, { region: topicRegion(message.TopicArn) })) {
164
+ throw new Error('confirmSubscription: SubscribeURL is not the SNS endpoint of the topic region')
165
+ }
166
+ const res = await fetchImpl(message.SubscribeURL, { signal: AbortSignal.timeout(TIMEOUT_MS), redirect: 'error' })
167
+ return { ok: res.ok, status: res.status }
168
+ }