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.
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