naijacloud-email 0.3.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +116 -0
- data/CONTRIBUTING.md +90 -0
- data/LICENSE +21 -0
- data/README.md +357 -0
- data/SECURITY.md +92 -0
- data/lib/naijacloud/email/client.rb +224 -0
- data/lib/naijacloud/email/emails.rb +443 -0
- data/lib/naijacloud/email/errors.rb +116 -0
- data/lib/naijacloud/email/http.rb +361 -0
- data/lib/naijacloud/email/objects.rb +203 -0
- data/lib/naijacloud/email/version.rb +7 -0
- data/lib/naijacloud/email/webhooks.rb +153 -0
- data/lib/naijacloud/email.rb +30 -0
- metadata +91 -0
data/SECURITY.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
## Reporting a vulnerability
|
|
4
|
+
|
|
5
|
+
Email **security@naijacloud.com**. Please do not open a public issue, and do not
|
|
6
|
+
post a proof of concept anywhere public before we have shipped a fix.
|
|
7
|
+
|
|
8
|
+
Include what you did, what happened, and the version of the gem. We acknowledge
|
|
9
|
+
within two business days (West Africa Time) and will tell you what we intend to
|
|
10
|
+
do and by when.
|
|
11
|
+
|
|
12
|
+
If you have found a **leaked Naijamail key** — in a repository, a log, a
|
|
13
|
+
screenshot — treat it as an incident and mail the same address. Revoke it from
|
|
14
|
+
the dashboard first: revocation is immediate.
|
|
15
|
+
|
|
16
|
+
## What this gem does with your key
|
|
17
|
+
|
|
18
|
+
The API key is a sending credential. A leaked one is a phishing incident on a
|
|
19
|
+
domain your customers trust, not an information disclosure, so the gem is built
|
|
20
|
+
to keep it in exactly one place.
|
|
21
|
+
|
|
22
|
+
1. **The key travels only in the `Authorization` header, over TLS.** It is never
|
|
23
|
+
put in a query string, a log line, the User-Agent, or an exception message.
|
|
24
|
+
2. **HTTPS is enforced at construction.** A `base_url` whose scheme is not
|
|
25
|
+
`https` is refused unless the host is `localhost`, `127.0.0.1` or `::1`, for
|
|
26
|
+
development against a local control plane. `verify_mode` is set explicitly to
|
|
27
|
+
`OpenSSL::SSL::VERIFY_PEER` rather than inherited from whatever else in the
|
|
28
|
+
process has touched OpenSSL's defaults.
|
|
29
|
+
3. **Redirects are never followed.** `net/http` does not follow them on its own,
|
|
30
|
+
and this gem additionally turns any 3xx into a hard `ServerError`. A followed
|
|
31
|
+
redirect re-sends the `Authorization` header to whatever host the response
|
|
32
|
+
names — that is how bearer tokens leak.
|
|
33
|
+
4. **The key is redacted everywhere it could be printed.** `Client#inspect` and
|
|
34
|
+
`Client#to_s` show `nmail_live_***`. The client does not keep the key as one
|
|
35
|
+
of its own instance variables — it is handed to the transport, whose `inspect`
|
|
36
|
+
is redacted too — so an error reporter that dumps the receiver's instance
|
|
37
|
+
variables finds nothing. `Marshal.dump` on a client raises rather than writing
|
|
38
|
+
a credential into a file nobody realises is a secret.
|
|
39
|
+
5. **There is no verbose or debug mode.** `Net::HTTP#set_debug_output` writes
|
|
40
|
+
every header, `Authorization` included, to whatever IO it is given. The gem
|
|
41
|
+
never calls it and offers no switch that would.
|
|
42
|
+
6. **Nothing is global.** Key, base URL and HTTP state live on the client
|
|
43
|
+
instance, so two clients holding two teams' keys cannot interfere.
|
|
44
|
+
|
|
45
|
+
## What the gem refuses before a request leaves your process
|
|
46
|
+
|
|
47
|
+
- **Header injection**: `\r`, `\n` or NUL anywhere in `from`, any address in
|
|
48
|
+
`to`/`cc`/`bcc`/`reply_to`, `subject`, a custom header name or value, an
|
|
49
|
+
attachment filename, the idempotency key or the User-Agent suffix. Message
|
|
50
|
+
bodies are exempt: they are content, not headers.
|
|
51
|
+
- **Forbidden custom headers**: `From`, `To`, `Cc`, `Bcc`, `Subject`,
|
|
52
|
+
`DKIM-Signature`, `Received`, case-insensitively. Overriding one would sidestep
|
|
53
|
+
the domain authorisation the From address is checked against.
|
|
54
|
+
- **Limits**: 50 recipients across to/cc/bcc, 25 custom headers, 10 tags, 10 MiB
|
|
55
|
+
encoded payload.
|
|
56
|
+
- **File paths in attachments.** `content:` takes bytes. The gem never opens a
|
|
57
|
+
path, because an SDK that opens whatever path it is handed is a
|
|
58
|
+
local-file-disclosure primitive in a web handler.
|
|
59
|
+
- **Message ids** that could alter the request path.
|
|
60
|
+
- **Keys of the wrong shape**, so an empty or truncated key fails on your machine
|
|
61
|
+
rather than as a 401 in production an hour after deploy.
|
|
62
|
+
|
|
63
|
+
## Webhook verification
|
|
64
|
+
|
|
65
|
+
`Webhooks.verify` compares signatures with `OpenSSL.secure_compare` where it
|
|
66
|
+
exists (Ruby 3.0+) and a constant-time XOR comparison otherwise — never `==`,
|
|
67
|
+
which stops at the first differing byte and leaks how much of a guess was right.
|
|
68
|
+
|
|
69
|
+
It signs and verifies the **raw request body**. It refuses a parsed Hash, because
|
|
70
|
+
re-serializing produces different bytes than the ones that were signed. The
|
|
71
|
+
timestamp is part of the signed payload and is checked against a 300-second
|
|
72
|
+
default tolerance, which is what stops a captured delivery being replayed. A
|
|
73
|
+
failed verification never returns the expected signature: that would hand an
|
|
74
|
+
attacker the answer.
|
|
75
|
+
|
|
76
|
+
## Supply chain
|
|
77
|
+
|
|
78
|
+
The gem has **zero runtime dependencies**. Everything is standard library:
|
|
79
|
+
`net/http`, `uri`, `json`, `openssl`, `securerandom`, `time`, `base64`.
|
|
80
|
+
|
|
81
|
+
That is a deliberate cost. This package is installed into processes that hold
|
|
82
|
+
live sending keys, so every third-party runtime dependency would be another
|
|
83
|
+
maintainer whose account compromise becomes our customers' incident. A faster
|
|
84
|
+
HTTP client is not worth that trade. Development and test dependencies (minitest,
|
|
85
|
+
rake) are not shipped to users.
|
|
86
|
+
|
|
87
|
+
Releases require MFA on the RubyGems account (`rubygems_mfa_required`).
|
|
88
|
+
|
|
89
|
+
## Supported versions
|
|
90
|
+
|
|
91
|
+
Security fixes are released for the latest minor version. Ruby 2.7 and newer are
|
|
92
|
+
supported.
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module NaijaCloud
|
|
6
|
+
module Email
|
|
7
|
+
# The entry point.
|
|
8
|
+
#
|
|
9
|
+
# nm = NaijaCloud::Email::Client.new # reads NAIJAMAIL_API_KEY
|
|
10
|
+
# nm.emails.send_email(from: ..., to: ..., subject: ..., html: ...)
|
|
11
|
+
#
|
|
12
|
+
# Everything a request needs -- key, base URL, timeouts, HTTP object -- hangs
|
|
13
|
+
# off the instance. No class-level configuration, no singleton: two clients
|
|
14
|
+
# holding two different teams' keys have to be able to run in one process
|
|
15
|
+
# without one quietly borrowing the other's credential.
|
|
16
|
+
class Client
|
|
17
|
+
DEFAULT_BASE_URL = "https://api.naijacloud.com"
|
|
18
|
+
|
|
19
|
+
API_KEY_ENV = "NAIJAMAIL_API_KEY"
|
|
20
|
+
BASE_URL_ENV = "NAIJAMAIL_BASE_URL"
|
|
21
|
+
|
|
22
|
+
# The shape the control plane mints (MAIL_KEY_PREFIX_LIVE / _TEST plus 24
|
|
23
|
+
# random bytes, base64url). Checked at construction so an empty string or a
|
|
24
|
+
# truncated copy-paste fails here, on the developer's machine, rather than
|
|
25
|
+
# as a 401 in production an hour after deploy.
|
|
26
|
+
#
|
|
27
|
+
# A test key is accepted by the constructor and refused by the send path
|
|
28
|
+
# with 403 -- that refusal is the server's job and is deliberate, so this
|
|
29
|
+
# SDK must not pre-empt it.
|
|
30
|
+
#
|
|
31
|
+
# Two families, because the API accepts two: `nmail_live_`/`nmail_test_`
|
|
32
|
+
# is a Naijamail-only key from the Email screen, and `nc_live_` is a
|
|
33
|
+
# workspace API key carrying the Email send scope, from Settings -> API
|
|
34
|
+
# keys. It stays an allowlist rather than relaxing to "any non-empty
|
|
35
|
+
# string": the check exists to catch the truncated paste and the
|
|
36
|
+
# wrong-variable-name deploy, and a pattern that accepts anything catches
|
|
37
|
+
# neither.
|
|
38
|
+
KEY_PATTERN = /\A(?:nmail_(?:live|test)|nc_live)_[A-Za-z0-9_-]{8,}\z/.freeze
|
|
39
|
+
|
|
40
|
+
# The only hosts allowed to be plaintext, for a developer running the
|
|
41
|
+
# control plane locally. Everything else must be https: a bearer key that
|
|
42
|
+
# can mail as a customer's verified domain has no business on the wire in
|
|
43
|
+
# clear, and "it was only staging" is how it gets there.
|
|
44
|
+
LOCAL_HOSTS = ["localhost", "127.0.0.1", "::1"].freeze
|
|
45
|
+
|
|
46
|
+
# More retries than this only stretches an outage into a hang: with the
|
|
47
|
+
# 8s backoff cap and a 30s per-attempt deadline, ten retries is already
|
|
48
|
+
# minutes of a caller blocked on one send.
|
|
49
|
+
MAX_RETRIES_LIMIT = 10
|
|
50
|
+
|
|
51
|
+
# The platform's pre-scope personal access token. The API refuses it on the
|
|
52
|
+
# mail routes; saying so here beats "not shaped like a key" (it is a real
|
|
53
|
+
# NaijaCloud credential, just the wrong kind). Wording fixed by SDK
|
|
54
|
+
# contract section 1, identical in all five SDKs.
|
|
55
|
+
PAT_PREFIX = "nc_pat_"
|
|
56
|
+
PAT_MESSAGE = "this is a personal access token (nc_pat_…), which cannot send mail; " \
|
|
57
|
+
"use a mail API key (nmail_live_… or nmail_test_…) or a workspace API key " \
|
|
58
|
+
"with the Email send scope (nc_live_…)"
|
|
59
|
+
|
|
60
|
+
attr_reader :base_url, :timeout, :max_retries, :user_agent
|
|
61
|
+
|
|
62
|
+
def initialize(api_key: nil, base_url: nil, timeout: 30, max_retries: 2, user_agent_suffix: nil)
|
|
63
|
+
key = resolve_api_key(api_key)
|
|
64
|
+
@base_uri = resolve_base_url(base_url)
|
|
65
|
+
@base_url = @base_uri.to_s
|
|
66
|
+
@timeout = validate_timeout(timeout)
|
|
67
|
+
@max_retries = validate_max_retries(max_retries)
|
|
68
|
+
@user_agent = build_user_agent(user_agent_suffix)
|
|
69
|
+
|
|
70
|
+
# The key is deliberately NOT kept as an instance variable of the client:
|
|
71
|
+
# it is handed to the transport, which is the only object that needs it,
|
|
72
|
+
# and the local goes out of scope here. Anything that walks a client's
|
|
73
|
+
# instance variables -- an error reporter serializing the receiver, a
|
|
74
|
+
# console session, a naive deep-inspect in a test failure -- then has
|
|
75
|
+
# nothing to find, because the transport redacts its own inspect too.
|
|
76
|
+
@key_display = NaijaCloud::Email.redact_key(key)
|
|
77
|
+
|
|
78
|
+
@http = Transport.new(
|
|
79
|
+
api_key: key,
|
|
80
|
+
base_url: @base_uri,
|
|
81
|
+
timeout: @timeout,
|
|
82
|
+
max_retries: @max_retries,
|
|
83
|
+
user_agent: @user_agent,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
@emails = Emails.new(@http)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The one resource. Two endpoints, because the server has two.
|
|
90
|
+
attr_reader :emails
|
|
91
|
+
|
|
92
|
+
# Both overridden, and neither mentions @api_key.
|
|
93
|
+
#
|
|
94
|
+
# This is not decoration. An unhandled exception prints the receiver's
|
|
95
|
+
# inspect, `p client` in a console prints it, a JSON logger calling to_s on
|
|
96
|
+
# its context prints it, and any of those is a live sending credential in a
|
|
97
|
+
# log aggregator that a much wider group of people can read than should
|
|
98
|
+
# ever see it. The default Object#inspect would print every instance
|
|
99
|
+
# variable, and the transport's key with it.
|
|
100
|
+
def inspect
|
|
101
|
+
"#<NaijaCloud::Email::Client base_url=#{@base_url} api_key=#{@key_display} " \
|
|
102
|
+
"timeout=#{@timeout} max_retries=#{@max_retries}>"
|
|
103
|
+
end
|
|
104
|
+
alias to_s inspect
|
|
105
|
+
|
|
106
|
+
# Marshal/YAML dumps of a client would carry the key into whatever wrote
|
|
107
|
+
# them. There is no reason to serialize a client, so refuse rather than
|
|
108
|
+
# produce a file nobody realises is a secret.
|
|
109
|
+
def marshal_dump
|
|
110
|
+
raise Error.new("a NaijaCloud::Email::Client holds an API key and must not be serialized")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Psych does not use marshal_dump: it walks instance variables, down
|
|
114
|
+
# @http to the key. encode_with is the hook it does call.
|
|
115
|
+
def encode_with(_coder)
|
|
116
|
+
raise Error.new("a NaijaCloud::Email::Client holds an API key and must not be serialized")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def resolve_api_key(api_key)
|
|
122
|
+
# ENV values pick up a trailing newline surprisingly often (`export
|
|
123
|
+
# KEY=$(cat key.txt)`), and a newline in a header value is a header
|
|
124
|
+
# injection, so strip before validating rather than after.
|
|
125
|
+
key = api_key.nil? ? ENV[API_KEY_ENV] : api_key
|
|
126
|
+
key = key.strip if key.is_a?(String)
|
|
127
|
+
|
|
128
|
+
if key.nil? || key.empty?
|
|
129
|
+
raise ValidationError.new(
|
|
130
|
+
"no API key. Pass api_key: to the constructor or set #{API_KEY_ENV} in the environment.",
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
raise ValidationError.new(PAT_MESSAGE) if key.is_a?(String) && key.start_with?(PAT_PREFIX)
|
|
134
|
+
|
|
135
|
+
unless key.is_a?(String) && key =~ KEY_PATTERN
|
|
136
|
+
# The key itself is never echoed, not even a "got: ..." fragment: this
|
|
137
|
+
# message goes straight into a log on a failed boot.
|
|
138
|
+
raise ValidationError.new(
|
|
139
|
+
"API key does not look like a Naijamail key " \
|
|
140
|
+
"(expected nmail_live_..., nmail_test_... or nc_live_...)",
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
key
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def resolve_base_url(base_url)
|
|
148
|
+
# A blank NAIJAMAIL_BASE_URL (an empty `export` in a deploy config) means
|
|
149
|
+
# "not set", not "invalid": the default applies, as in every other SDK.
|
|
150
|
+
env = ENV[BASE_URL_ENV]
|
|
151
|
+
env = nil if env.nil? || env.strip.empty?
|
|
152
|
+
|
|
153
|
+
raw = base_url || env || DEFAULT_BASE_URL
|
|
154
|
+
raw = raw.to_s.strip
|
|
155
|
+
|
|
156
|
+
uri =
|
|
157
|
+
begin
|
|
158
|
+
URI.parse(raw)
|
|
159
|
+
rescue URI::InvalidURIError => e
|
|
160
|
+
raise ValidationError.new("base_url is not a valid URL: #{e.message}")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
unless uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty?
|
|
164
|
+
raise ValidationError.new("base_url must be an absolute http(s) URL, got #{raw.inspect}")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# A query or fragment would sit after every path this client appends, so
|
|
168
|
+
# the request would go somewhere other than where the caller thinks.
|
|
169
|
+
# Refused rather than stripped: silently dropping part of a configured
|
|
170
|
+
# URL hides a configuration mistake.
|
|
171
|
+
if uri.query || uri.fragment
|
|
172
|
+
raise ValidationError.new("base_url must not contain a query string or a fragment")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# URI#host keeps the brackets on an IPv6 literal ("[::1]"); #hostname
|
|
176
|
+
# strips them.
|
|
177
|
+
host = uri.hostname.downcase
|
|
178
|
+
|
|
179
|
+
if uri.scheme != "https" && !LOCAL_HOSTS.include?(host)
|
|
180
|
+
raise ValidationError.new(
|
|
181
|
+
"base_url must use https (got #{uri.scheme.inspect} for host #{host.inspect}). " \
|
|
182
|
+
"Plaintext is allowed only for localhost, 127.0.0.1 and ::1.",
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
uri
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def validate_timeout(timeout)
|
|
190
|
+
unless timeout.is_a?(Numeric) && timeout.to_f > 0
|
|
191
|
+
raise ValidationError.new("timeout must be a positive number of seconds")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
timeout.to_f
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def validate_max_retries(max_retries)
|
|
198
|
+
unless max_retries.is_a?(Integer) && max_retries >= 0 && max_retries <= MAX_RETRIES_LIMIT
|
|
199
|
+
raise ValidationError.new("max_retries must be an Integer from 0 to #{MAX_RETRIES_LIMIT}")
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
max_retries
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Identifies the SDK in our logs, which is how we tell an SDK bug from a
|
|
206
|
+
# customer's hand-rolled client. The suffix is checked for CR/LF for the
|
|
207
|
+
# same reason every other header value is: it ends up in one.
|
|
208
|
+
def build_user_agent(suffix)
|
|
209
|
+
agent = "nc-email-ruby/#{VERSION} (ruby/#{RUBY_VERSION})"
|
|
210
|
+
return agent if suffix.nil?
|
|
211
|
+
|
|
212
|
+
unless suffix.is_a?(String)
|
|
213
|
+
raise ValidationError.new("user_agent_suffix must be a string")
|
|
214
|
+
end
|
|
215
|
+
if suffix =~ /[\r\n\0]/
|
|
216
|
+
raise ValidationError.new("user_agent_suffix contains a line break or NUL")
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
suffix = suffix.strip
|
|
220
|
+
suffix.empty? ? agent : "#{agent} #{suffix}"
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|