mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-09-30 14:13:13 +00:00
Some checks are pending
Deploy / deploy (push) Waiting to run
* Add fail-closed SES sender authentication
The From header and any raw-MIME Authentication-Results copies are
attacker-forgeable, so a forged email to apm@int.seahaven.com or
amazon_po@int.seahaven.com could create or mutate a WO/PO (INFRA-107,
CRITICAL). Both S3-triggered email processors now authenticate the
sender against the Authentication-Results header SES itself prepends
at delivery: only the topmost header is consulted, its authserv-id
must be amazonses.com, and it must carry dkim=pass for a domain in
the per-pipeline ALLOWED_DKIM_DOMAINS env var (comma-separated, set
in CDK so ops can adjust without code changes).
Allowlists come from live traffic observed 2026-07-15 on both ingest
buckets: WO mail arrives via the apm@ Google Groups forward, which
re-signs as seahaven.com (the hxgnsmartcloud.com signature does not
survive the forward); PO mail passes for amazon.coupahost.com.
amazonses.com also passes on PO mail but is deliberately excluded --
every SES customer's outbound mail passes for it.
Every failure path (env var unset, header missing or unparseable,
verdict fail, unaligned domain) rejects the email: a structured
warning with the reason and S3 key is logged and the record skipped
without erroring the invocation, so rejected mail causes no Lambda
retries or DLQ messages. Handler signatures and event sources are
unchanged.
Refs: INFRA-107
* Harden AR parser per cross-family review
Cross-family (GPT-4.1) review findings: terminate the dkim result
token at end-of-clause, whitespace, or a comment so a value like
"dkim=pass-fake" can never be read as a pass; normalize trailing
dots off allowlist entries so "seahaven.com." matches; make the
compat32 parser policy explicit. Adds tests for result-token
boundaries, comments after the result, quoted domain values, and
folding inside a dkim clause.
Refs: INFRA-107
* Harden AR parsing and alarm on sender-auth rejects
The SES-stamped Authentication-Results value echoes attacker-controlled
SMTP-session tokens (envelope-from, helo, header.from) as their own
semicolon-delimited property clauses. A naive split(";") tore an RFC 5321
quoted-local-part MAIL FROM apart and manufactured a forged dkim=pass
clause, so a fully spoofed email was accepted on the genuinely
SES-stamped topmost header. Tokenise comment- and quoted-string-aware
(RFC 8601 / RFC 5322): strip CFWS comments, split clauses only on
semicolons outside a quoted-string, and fail closed on unbalanced
quotes/comments so a ';' inside a quoted pvalue can never start a clause.
Rejected mail returns normally (no error, no retry, no DLQ message), so a
signing-domain drift or a wrong allowlist would silently discard 100% of
legitimate mail while every alarm stayed green. Add a CloudWatch Logs
metric filter + alarm on the sender_auth_rejected warning to both stacks
so a false-reject storm pages instead of vanishing. This is also the
safety net for the WO seahaven.com allowlist assumption, which must be
validated against a live SES-stamped header (a plain Gmail auto-forward
re-signs under the sending Workspace domain, not seahaven.com).
Refs: INFRA-107
* chore: retrigger CI (no run recorded for 7c74ac1)
* Fix quoted-AUID DKIM domain spoof in sender auth
Resolve three confirmed /sh-security-review findings on the fail-closed
SES sender-authentication control.
HIGH: header.i/header.d domain extraction was not quoted-string aware.
An attacker with a valid DKIM key for their own domain could set an
RFC 6376-legal AUID such as i="@seahaven.com"@attacker.com; the naive
extractor stopped at the closing quote and returned seahaven.com,
accepting forged mail. Extraction now tokenises the clause with the same
quoted-string discipline already used for clause splitting: header.d
(the plain signing domain) is authoritative when present, otherwise the
header.i domain is the part after the AUID's LAST top-level "@", so a "@"
inside a quoted local-part is treated as signer-controlled label text and
yields the true signer (attacker.com), not seahaven.com.
LOW: the topmost-header parse ran outside evaluate_sender_authentication's
try/except, so an unexpected parser exception on crafted input could
propagate into the handler and Lambda async retries/DLQ. The parse now
fails CLOSED with an authentication_results_unparseable reason.
MEDIUM: the sender_auth_rejected alarm used Sum>=3 over 15 min, blind to
a low-volume total-reject outage (a trickle that never sums to 3). Both
stacks now alarm on >=1 reject per 5-min period with evaluation_periods=3
/ datapoints_to_alarm=2, so a sustained reject condition pages even at one
reject per period while a lone stray probe self-clears.
Refs: INFRA-107
* Load Lambda function dir on sys.path in tests
Rebasing INFRA-107 onto main folded #95's pytest suite into this
branch's tests. The unified conftest loads the PO/WO handlers by file
path, and handler.py now does `from ses_auth import
authenticate_inbound_email` -- a bare sibling import that resolves in
the Lambda only because the runtime puts each function's own directory
on sys.path. The shared load_handler now adds that directory so the
handler tests import correctly alongside the sender-auth tests.
Refs: INFRA-107
* Note #97 test files in README directory tree
The rebase onto main brought in #97's tests/requirements.txt and
tests/test_po_merge.py. List both in the directory tree so it matches
the tree on disk.
Refs: INFRA-107
* Document INFRA-107 forwarder-binding risk acceptance
Record the accepted risk that WO sender auth binds to the apm@ forward's
re-signing domain (seahaven.com) rather than the Hexagon originator; the
apm@ Google Group's restricted posting policy is the load-bearing control
(escalates to HIGH if the group is opened to external posting). Also
correct the sender-auth-rejected alarm docs to match the shipped config
(>=1 per 5-min, 2-of-3 datapoints, not the superseded >=3/15min) and
note the SES-AR-01/02 parser hardening follow-ups.
Refs: INFRA-107
391 lines
17 KiB
Python
391 lines
17 KiB
Python
"""Unit tests for the fail-closed SES sender authentication (INFRA-107).
|
|
|
|
Fixtures mirror real SES-stamped headers observed on the two ingest
|
|
buckets on 2026-07-15: SES prepends a folded Authentication-Results
|
|
header with authserv-id amazonses.com and reports passing signers as
|
|
``dkim=pass header.i=@<domain>``.
|
|
"""
|
|
|
|
BODY = "\r\n\r\nWork Order 12345 assigned.\r\n"
|
|
|
|
# Folded exactly like real SES output (continuation lines, header.i form).
|
|
WO_SES_HEADER = (
|
|
"Authentication-Results: amazonses.com;\r\n"
|
|
" spf=pass (spfCheck: domain of seahaven.com designates 209.85.219.70 as"
|
|
" permitted sender) client-ip=209.85.219.70;"
|
|
" envelope-from=apm+bnc@seahaven.com; helo=mail-qv1-f70.google.com;\r\n"
|
|
" dkim=pass header.i=@seahaven.com;\r\n"
|
|
" dmarc=none header.from=hxgnsmartcloud.com;\r\n"
|
|
)
|
|
|
|
# Real PO traffic carries two dkim=pass clauses; amazonses.com must not be
|
|
# sufficient on its own (every SES customer's mail passes for it).
|
|
PO_SES_HEADER = (
|
|
"Authentication-Results: amazonses.com;\r\n"
|
|
" spf=pass (spfCheck: domain of mail.coupahost.com designates"
|
|
" 54.240.41.238 as permitted sender) client-ip=54.240.41.238;\r\n"
|
|
" dkim=pass header.i=@amazonses.com;\r\n"
|
|
" dkim=pass header.i=@amazon.coupahost.com;\r\n"
|
|
" dmarc=pass header.from=amazon.coupahost.com;\r\n"
|
|
)
|
|
|
|
FROM_TO = (
|
|
"From: APM <noreply@hxgnsmartcloud.com>\r\n"
|
|
"To: apm@int.seahaven.com\r\n"
|
|
"Subject: WO 12345\r\n"
|
|
)
|
|
|
|
|
|
def raw(*headers: str) -> bytes:
|
|
return ("".join(headers) + FROM_TO + BODY).encode()
|
|
|
|
|
|
class TestParseAuthenticationResults:
|
|
def test_ses_wo_header(self, ses_auth):
|
|
value = WO_SES_HEADER.split(":", 1)[1]
|
|
authserv_id, passing = ses_auth.parse_authentication_results(value)
|
|
assert authserv_id == "amazonses.com"
|
|
assert passing == frozenset({"seahaven.com"})
|
|
|
|
def test_ses_po_header_multiple_dkim_clauses(self, ses_auth):
|
|
value = PO_SES_HEADER.split(":", 1)[1]
|
|
authserv_id, passing = ses_auth.parse_authentication_results(value)
|
|
assert authserv_id == "amazonses.com"
|
|
assert passing == frozenset({"amazonses.com", "amazon.coupahost.com"})
|
|
|
|
def test_header_d_form(self, ses_auth):
|
|
_, passing = ses_auth.parse_authentication_results(
|
|
"amazonses.com; dkim=pass header.d=Example.COM."
|
|
)
|
|
assert passing == frozenset({"example.com"})
|
|
|
|
def test_case_insensitive_result(self, ses_auth):
|
|
_, passing = ses_auth.parse_authentication_results(
|
|
"amazonses.com; DKIM=Pass HEADER.I=@SeaHaven.COM"
|
|
)
|
|
assert passing == frozenset({"seahaven.com"})
|
|
|
|
def test_dkim_fail_yields_no_domains(self, ses_auth):
|
|
_, passing = ses_auth.parse_authentication_results(
|
|
"amazonses.com; dkim=fail header.i=@seahaven.com"
|
|
)
|
|
assert passing == frozenset()
|
|
|
|
def test_authserv_id_version_token(self, ses_auth):
|
|
authserv_id, _ = ses_auth.parse_authentication_results(
|
|
"amazonses.com 1; dkim=pass header.i=@seahaven.com"
|
|
)
|
|
assert authserv_id == "amazonses.com"
|
|
|
|
def test_garbage_value(self, ses_auth):
|
|
authserv_id, passing = ses_auth.parse_authentication_results(";;;")
|
|
assert authserv_id == ""
|
|
assert passing == frozenset()
|
|
|
|
def test_result_token_boundary(self, ses_auth):
|
|
# "pass-anything" / "passfail" must never be read as "pass".
|
|
for result in ("pass-fake", "passfail"):
|
|
_, passing = ses_auth.parse_authentication_results(
|
|
f"amazonses.com; dkim={result} header.i=@seahaven.com"
|
|
)
|
|
assert passing == frozenset()
|
|
|
|
def test_result_followed_by_comment(self, ses_auth):
|
|
_, passing = ses_auth.parse_authentication_results(
|
|
"amazonses.com; dkim=pass(good signature) header.i=@seahaven.com"
|
|
)
|
|
assert passing == frozenset({"seahaven.com"})
|
|
|
|
def test_quoted_auid_localpart_is_not_the_domain(self, ses_auth):
|
|
# header.i="@seahaven.com" is a *quoted local-part* with no top-level
|
|
# "@domain" -- per RFC 6376 there is no identity domain, so it must
|
|
# yield nothing rather than mistaking the quoted label for the domain.
|
|
_, passing = ses_auth.parse_authentication_results(
|
|
'amazonses.com; dkim=pass header.i="@seahaven.com"'
|
|
)
|
|
assert passing == frozenset()
|
|
|
|
def test_quoted_auid_localpart_attack_yields_true_signer(self, ses_auth):
|
|
# The core INFRA-107 defect: a signer with their own valid DKIM key for
|
|
# attacker.com sets AUID i="@seahaven.com"@attacker.com (RFC 6376-legal:
|
|
# the quoted local-part contains an "@"). The identity domain is the
|
|
# part after the LAST top-level "@" -> attacker.com, NOT seahaven.com.
|
|
_, passing = ses_auth.parse_authentication_results(
|
|
'amazonses.com; dkim=pass header.i="@seahaven.com"@attacker.com'
|
|
)
|
|
assert passing == frozenset({"attacker.com"})
|
|
assert "seahaven.com" not in passing
|
|
|
|
def test_quoted_auid_attack_rejected_end_to_end(self, ses_auth):
|
|
# Same attack through the full evaluator against a seahaven.com
|
|
# allowlist: the genuine dkim=pass binds to attacker.com, so the forged
|
|
# mail must fail closed, not be accepted for seahaven.com.
|
|
forged = (
|
|
"Authentication-Results: amazonses.com;\r\n"
|
|
' dkim=pass header.i="@seahaven.com"@attacker.com;\r\n'
|
|
)
|
|
accepted, reason, _ = ses_auth.evaluate_sender_authentication(
|
|
raw(forged), {"seahaven.com"}
|
|
)
|
|
assert not accepted
|
|
assert reason == "dkim_domain_not_allowlisted"
|
|
|
|
def test_header_d_takes_precedence_over_mismatched_header_i(self, ses_auth):
|
|
# header.d is the authoritative signing domain; when both appear it
|
|
# wins over header.i, whichever order SES emits them in.
|
|
for clause in (
|
|
"dkim=pass header.i=@evil.com header.d=seahaven.com",
|
|
"dkim=pass header.d=seahaven.com header.i=@evil.com",
|
|
):
|
|
_, passing = ses_auth.parse_authentication_results(
|
|
f"amazonses.com; {clause}"
|
|
)
|
|
assert passing == frozenset({"seahaven.com"})
|
|
|
|
def test_header_d_smuggled_in_quoted_header_i_is_not_top_level(self, ses_auth):
|
|
# A "header.d=seahaven.com" literal hidden inside header.i's quoted
|
|
# local-part must not be read as a top-level header.d property; the real
|
|
# (top-level) header.d=attacker.com is authoritative.
|
|
_, passing = ses_auth.parse_authentication_results(
|
|
'amazonses.com; dkim=pass header.i="header.d=seahaven.com x"@attacker.com'
|
|
" header.d=attacker.com"
|
|
)
|
|
assert passing == frozenset({"attacker.com"})
|
|
|
|
def test_legitimate_unquoted_header_i_still_passes(self, ses_auth):
|
|
_, passing = ses_auth.parse_authentication_results(
|
|
"amazonses.com; dkim=pass header.i=@seahaven.com"
|
|
)
|
|
assert passing == frozenset({"seahaven.com"})
|
|
|
|
def test_folding_inside_dkim_clause(self, ses_auth):
|
|
_, passing = ses_auth.parse_authentication_results(
|
|
"amazonses.com;\r\n dkim=pass\r\n header.i=@seahaven.com"
|
|
)
|
|
assert passing == frozenset({"seahaven.com"})
|
|
|
|
def test_quoted_semicolon_in_envelope_from_is_not_split(self, ses_auth):
|
|
# RFC 5321 quoted-local-part MAIL FROM can carry ';' and spaces; SES
|
|
# echoes it into envelope-from=. The ';' inside the quotes must stay
|
|
# part of the one envelope-from clause, never a synthetic dkim clause.
|
|
value = (
|
|
"amazonses.com; spf=pass client-ip=1.2.3.4;"
|
|
' envelope-from="x; dkim=pass header.i=@amazon.coupahost.com"@attacker.com;'
|
|
" helo=mail.attacker.com; dkim=fail header.i=@attacker.com;"
|
|
)
|
|
_, passing = ses_auth.parse_authentication_results(value)
|
|
assert passing == frozenset()
|
|
|
|
def test_quoted_pair_in_envelope_from_is_not_split(self, ses_auth):
|
|
# A quoted-pair (\") inside the quoted local part must not prematurely
|
|
# end the quoted-string and re-expose the smuggled tokens.
|
|
value = (
|
|
'amazonses.com; envelope-from="a\\"; dkim=pass header.d=seahaven.com"@evil.com;'
|
|
" dkim=fail header.i=@evil.com;"
|
|
)
|
|
_, passing = ses_auth.parse_authentication_results(value)
|
|
assert passing == frozenset()
|
|
|
|
def test_helo_injection_is_not_split(self, ses_auth):
|
|
# helo= is attacker-influenced too; a crafted value quoting a fake
|
|
# methodspec must not manufacture a passing clause.
|
|
value = (
|
|
'amazonses.com; helo="h; dkim=pass header.i=@seahaven.com";'
|
|
" dkim=fail header.i=@attacker.com;"
|
|
)
|
|
_, passing = ses_auth.parse_authentication_results(value)
|
|
assert passing == frozenset()
|
|
|
|
def test_comment_embedded_header_i_is_ignored(self, ses_auth):
|
|
# A CFWS comment carrying a fake header.i must be stripped before
|
|
# domain extraction, so only the real (failing) result is considered.
|
|
_, passing = ses_auth.parse_authentication_results(
|
|
"amazonses.com; dkim=fail (header.i=@seahaven.com) header.i=@attacker.com"
|
|
)
|
|
assert passing == frozenset()
|
|
|
|
def test_comment_hiding_semicolon_does_not_split(self, ses_auth):
|
|
# A ';' inside a comment is not a clause separator either.
|
|
_, passing = ses_auth.parse_authentication_results(
|
|
"amazonses.com; spf=pass (note: a; b) client-ip=1.2.3.4;"
|
|
" dkim=pass header.i=@seahaven.com"
|
|
)
|
|
assert passing == frozenset({"seahaven.com"})
|
|
|
|
def test_unbalanced_quote_fails_closed(self, ses_auth):
|
|
# An unterminated quoted-string is malformed; refuse to parse it so a
|
|
# dkim=pass clause "swallowed" by the runaway quote can't be salvaged
|
|
# (and, conversely, a runaway quote can't be used to mis-tokenise).
|
|
authserv_id, passing = ses_auth.parse_authentication_results(
|
|
'amazonses.com; envelope-from="oops@attacker.com;'
|
|
" dkim=pass header.i=@seahaven.com"
|
|
)
|
|
assert authserv_id == ""
|
|
assert passing == frozenset()
|
|
|
|
def test_unbalanced_comment_fails_closed(self, ses_auth):
|
|
# An unterminated comment is malformed and must fail closed.
|
|
authserv_id, passing = ses_auth.parse_authentication_results(
|
|
"amazonses.com; dkim=pass header.i=@seahaven.com (runaway comment"
|
|
)
|
|
assert authserv_id == ""
|
|
assert passing == frozenset()
|
|
|
|
|
|
class TestEvaluateSenderAuthentication:
|
|
def test_ses_stamped_pass_accepted(self, ses_auth):
|
|
accepted, reason, detail = ses_auth.evaluate_sender_authentication(
|
|
raw(WO_SES_HEADER), {"seahaven.com"}
|
|
)
|
|
assert accepted
|
|
assert reason == "authenticated"
|
|
assert detail["matched_domains"] == ["seahaven.com"]
|
|
|
|
def test_po_pass_accepted_on_coupa_domain(self, ses_auth):
|
|
accepted, reason, _ = ses_auth.evaluate_sender_authentication(
|
|
raw(PO_SES_HEADER), {"amazon.coupahost.com"}
|
|
)
|
|
assert accepted
|
|
assert reason == "authenticated"
|
|
|
|
def test_amazonses_identity_alone_is_not_allowlisted(self, ses_auth):
|
|
# Any SES customer's outbound mail passes DKIM for amazonses.com,
|
|
# so a pass for it must not satisfy a coupahost-only allowlist.
|
|
forged_via_ses = (
|
|
"Authentication-Results: amazonses.com;\r\n"
|
|
" spf=pass client-ip=54.240.41.1;\r\n"
|
|
" dkim=pass header.i=@amazonses.com;\r\n"
|
|
)
|
|
accepted, reason, _ = ses_auth.evaluate_sender_authentication(
|
|
raw(forged_via_ses), {"amazon.coupahost.com"}
|
|
)
|
|
assert not accepted
|
|
assert reason == "dkim_domain_not_allowlisted"
|
|
|
|
def test_forged_ar_below_failing_ses_header_rejected(self, ses_auth):
|
|
# SES's (topmost) header says dkim=fail; the attacker smuggled a
|
|
# perfect-looking AR header inside the message. Only the topmost
|
|
# header may be consulted.
|
|
ses_fail = (
|
|
"Authentication-Results: amazonses.com;\r\n"
|
|
" spf=fail client-ip=203.0.113.7;\r\n"
|
|
" dkim=fail header.i=@seahaven.com;\r\n"
|
|
" dmarc=fail header.from=hxgnsmartcloud.com;\r\n"
|
|
)
|
|
forged = (
|
|
"Authentication-Results: amazonses.com;\r\n"
|
|
" dkim=pass header.i=@seahaven.com;\r\n"
|
|
)
|
|
accepted, reason, _ = ses_auth.evaluate_sender_authentication(
|
|
raw(ses_fail, forged), {"seahaven.com"}
|
|
)
|
|
assert not accepted
|
|
assert reason == "no_passing_dkim_signature"
|
|
|
|
def test_missing_ar_header_rejected(self, ses_auth):
|
|
accepted, reason, _ = ses_auth.evaluate_sender_authentication(
|
|
raw(), {"seahaven.com"}
|
|
)
|
|
assert not accepted
|
|
assert reason == "authentication_results_missing"
|
|
|
|
def test_untrusted_authserv_id_rejected(self, ses_auth):
|
|
attacker_ar = (
|
|
"Authentication-Results: mail.attacker.example;\r\n"
|
|
" dkim=pass header.i=@seahaven.com;\r\n"
|
|
)
|
|
accepted, reason, _ = ses_auth.evaluate_sender_authentication(
|
|
raw(attacker_ar), {"seahaven.com"}
|
|
)
|
|
assert not accepted
|
|
assert reason == "untrusted_authserv_id"
|
|
|
|
def test_unaligned_domain_rejected(self, ses_auth):
|
|
evil = (
|
|
"Authentication-Results: amazonses.com;\r\n"
|
|
" dkim=pass header.i=@evil.example.com;\r\n"
|
|
)
|
|
accepted, reason, _ = ses_auth.evaluate_sender_authentication(
|
|
raw(evil), {"seahaven.com"}
|
|
)
|
|
assert not accepted
|
|
assert reason == "dkim_domain_not_allowlisted"
|
|
|
|
def test_lookalike_domain_rejected(self, ses_auth):
|
|
# Substring containment must not match: notseahaven.com != seahaven.com
|
|
lookalike = (
|
|
"Authentication-Results: amazonses.com;\r\n"
|
|
" dkim=pass header.i=@notseahaven.com;\r\n"
|
|
)
|
|
accepted, _, _ = ses_auth.evaluate_sender_authentication(
|
|
raw(lookalike), {"seahaven.com"}
|
|
)
|
|
assert not accepted
|
|
|
|
def test_empty_allowlist_fails_closed(self, ses_auth):
|
|
accepted, reason, _ = ses_auth.evaluate_sender_authentication(
|
|
raw(WO_SES_HEADER), frozenset()
|
|
)
|
|
assert not accepted
|
|
assert reason == "allowlist_not_configured"
|
|
|
|
def test_parser_exception_fails_closed(self, ses_auth, monkeypatch):
|
|
# The parse of the topmost AR header runs outside the BytesParser
|
|
# try/except; an unexpected parser exception on crafted input must fail
|
|
# CLOSED (rejected, structured reason) rather than propagate out of the
|
|
# handler into Lambda's async retries / DLQ.
|
|
def boom(_value):
|
|
raise ValueError("simulated parser blow-up")
|
|
|
|
monkeypatch.setattr(ses_auth, "parse_authentication_results", boom)
|
|
accepted, reason, _ = ses_auth.evaluate_sender_authentication(
|
|
raw(WO_SES_HEADER), {"seahaven.com"}
|
|
)
|
|
assert not accepted
|
|
assert reason == "authentication_results_unparseable"
|
|
|
|
def test_envelope_from_clause_injection_rejected(self, ses_auth):
|
|
# Full forged email: attacker's quoted MAIL FROM is echoed by SES into
|
|
# its own (topmost, genuinely SES-stamped) Authentication-Results as
|
|
# envelope-from=, trying to smuggle a dkim=pass for an allowlisted
|
|
# domain. The real DKIM verdict is fail. Must fail closed.
|
|
injected = (
|
|
"Authentication-Results: amazonses.com;\r\n"
|
|
" spf=pass (spfCheck: domain of attacker.com designates 1.2.3.4 as"
|
|
" permitted sender) client-ip=1.2.3.4;\r\n"
|
|
' envelope-from="x; dkim=pass header.i=@amazon.coupahost.com"@attacker.com;'
|
|
" helo=mail.attacker.com;\r\n"
|
|
" dkim=fail header.i=@attacker.com;\r\n"
|
|
" dmarc=fail header.from=attacker.com;\r\n"
|
|
)
|
|
accepted, reason, _ = ses_auth.evaluate_sender_authentication(
|
|
raw(injected), {"amazon.coupahost.com", "seahaven.com"}
|
|
)
|
|
assert not accepted
|
|
assert reason == "no_passing_dkim_signature"
|
|
|
|
|
|
class TestAuthenticateInboundEmail:
|
|
S3_KEY = "s3://bucket/inbound/abc123"
|
|
|
|
def test_accepts_with_configured_allowlist(self, ses_auth, monkeypatch):
|
|
monkeypatch.setenv("ALLOWED_DKIM_DOMAINS", "seahaven.com")
|
|
assert ses_auth.authenticate_inbound_email(raw(WO_SES_HEADER), self.S3_KEY)
|
|
|
|
def test_allowlist_is_comma_separated_and_normalized(self, ses_auth, monkeypatch):
|
|
# Stray spaces, case, a leading @, and a trailing dot all normalize.
|
|
monkeypatch.setenv("ALLOWED_DKIM_DOMAINS", " Other.Example , @SEAHAVEN.com. ,")
|
|
assert ses_auth.authenticate_inbound_email(raw(WO_SES_HEADER), self.S3_KEY)
|
|
|
|
def test_env_var_unset_fails_closed(self, ses_auth, monkeypatch, caplog):
|
|
monkeypatch.delenv("ALLOWED_DKIM_DOMAINS", raising=False)
|
|
assert not ses_auth.authenticate_inbound_email(raw(WO_SES_HEADER), self.S3_KEY)
|
|
assert "allowlist_not_configured" in caplog.text
|
|
assert self.S3_KEY in caplog.text
|
|
|
|
def test_rejection_logs_reason_and_key(self, ses_auth, monkeypatch, caplog):
|
|
monkeypatch.setenv("ALLOWED_DKIM_DOMAINS", "seahaven.com")
|
|
assert not ses_auth.authenticate_inbound_email(raw(), self.S3_KEY)
|
|
assert "sender_auth_rejected" in caplog.text
|
|
assert "authentication_results_missing" in caplog.text
|
|
assert self.S3_KEY in caplog.text
|