@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/CHANGELOG.md +13 -0
- package/README.md +344 -2
- package/encode.js +80 -0
- package/escape.js +15 -0
- package/index.d.ts +95 -0
- package/index.js +4 -0
- package/mailer.js +154 -0
- package/message.js +245 -0
- package/mime.js +106 -0
- package/package.json +59 -3
- package/sendError.js +51 -0
- package/sns.d.ts +46 -0
- package/sns.js +168 -0
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
|
+
}
|