refactor(mailing): one HTML shell and one encoder for every module - #1364
Open
marcelo-maciel wants to merge 6 commits into
Open
refactor(mailing): one HTML shell and one encoder for every module#1364marcelo-maciel wants to merge 6 commits into
marcelo-maciel wants to merge 6 commits into
Conversation
Every provider puts MailRequest.Body in the HTML slot — MailKit's BodyBuilder.HtmlBody, SendGrid's htmlContent — but the password-reset and welcome mails passed plain text. A bare URL inside an HTML part is not auto-linked by most clients, so the reset link arrived as dead text and the user had no way to complete the flow. The welcome mail additionally interpolated the user-supplied first name straight into that HTML. MailRequest gains an optional TextBody carrying the text/plain alternative. SmtpMailService emits both parts as multipart/alternative; SendGridMailService stops passing Body as plainTextContent, which had been shipping raw markup to text-only clients. Identity builds its bodies through EmailBodies, which HTML-encodes every interpolated value, and billing bodies gained their plain twin so no message goes out HTML-only. Verified: build -warnaserror 0/0; unit suites green (Identity 317, Framework 122, Billing 123, and the rest).
The test hosts pull 10.0.8 transitively, which carries HIGH-severity advisories (GHSA-23rf-6693-g89p, GHSA-8q5v-6pqq-x66h, GHSA-cvvh-rhrc-wg4q, GHSA-g8r8-53c2-pm3f) and trips NuGetAudit under TreatWarningsAsErrors, breaking the build of every test project. 10.0.10 is the patched servicing release. Mirrors the existing Microsoft.OpenApi transitive pin.
# Conflicts: # src/Directory.Packages.props
…1333 is open `NU1903` / `GHSA-q939-rpr3-3284` on `SSH.NET` 2025.1.0, pulled transitively by Testcontainers, fails `restore` for the whole solution under `TreatWarningsAsErrors` — on `main` too. It is not introduced here and the fix belongs to fullstackhero#1333, which is still open. Carried byte-identical to fullstackhero#1333's version of the file, comment included, so both stay mergeable in either order and this copy can simply be dropped once fullstackhero#1333 lands.
Follow-up to the nit on fullstackhero#1351: `EmailBodies` (Identity) and `BillingEmailBodies.Wrap` (Notifications) had grown into two independent HTML shells with two different escapers, and they would have drifted. - New `FSH.Framework.Mailing.HtmlEmail` holds the document shell (doctype, charset, viewport, card) and the encoder. Both modules already referenced the Mailing building block, so no new project reference. - `Encode` is `WebUtility.HtmlEncode` everywhere. The hand-rolled four-`Replace` chain in Notifications covered only `&`, `<` and `>` — safe in element content, not in an attribute — and is gone. - `EmailBodies` is deleted; its two callers use `HtmlEmail` directly rather than a pass-through. - Billing mail now renders in the same document as identity mail, so it gains a doctype and a `<meta charset>` it did not have. `Shell` takes trusted markup and does not encode it; the doc comment says so and a test pins it, because "hardening" that would render every e-mail as visible tags.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
…the HTML part `amountText` embeds `currency`, which is data rather than a literal, and was the only value in this file reaching the markup unencoded — `invoiceNumber`, `tenantName` and `plan` were all escaped already. Not a vulnerability today, and the description says so: the only writer is `CreatePlanCommand`, capped at three characters by its validator and gated by `BillingPermissions.Manage`, while the top-up path passes a hardcoded "USD". Three characters in element content cannot form a working payload. This is consistency and defence in depth: the only thing standing between the value and the markup is a length rule in another module. No-op for every real currency code: encoding "100.00 USD" returns it unchanged.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Takes you up on the non-blocking nit from your approval of #1351:
EmailBodies(Identity) andBillingEmailBodies.Wrap(Notifications) as two independent shells with two escapers, the hand-rolled one being the weaker.src/BuildingBlocks(Golden Rule #4, requesting sign-off)One file, and it is an addition:
Mailing/HtmlEmail.cs. Nothing existing underBuildingBlocksis modified — no signature, no behaviour, no registration. It holds the document shell (doctype, charset, viewport, card) andEncode.What changes
EncodeisWebUtility.HtmlEncodeeverywhere. The four-Replacechain covered only&,<and>. That is safe in element content and not in an attribute, which is exactly the drift you predicted.EmailBodiesis deleted rather than left as a pass-through. Its two callers useHtmlEmaildirectly. Both modules already referenced the Mailing building block, so there is no new project reference.BillingEmailBodieskeeps its own copy and itsText()twin, and loses onlyWrapandEscape. The automated-message footer stays with the billing copy, since it is the one part genuinely specific to those e-mails.Behaviour change, stated rather than buried
Billing mail now renders in the same document as identity mail, so it gains a doctype, a
<meta charset>and the card. Previously it was a bare<div>fragment with no charset declared. Visible to the recipient.Accented text in billing mail becomes numeric entities (
çbecomesç), becauseWebUtility.HtmlEncodeentitises the Latin-1 supplement while the old escaper left it raw. Renders identically and is more robust to a mis-declared charset. Identity mail already behaved this way. Worth knowing that the encoder is asymmetric here: characters above 255 (CJK, for instance) stay verbatim and rely on the declared utf-8. Pinned by a test so it is documented rather than discovered.The one contract that needs care
Shell(heading, innerHtml)insertsinnerHtmlverbatim. That is the point — callers compose markup from literals plusEncoded values — but it means a future contributor "hardening" it by encoding would render every e-mail as visible tags. The doc comment says so and a test pins it.Tests
15 new cases in
HtmlEmailTests: script tags, quotes and apostrophes, already-escaped&encoded exactly once, the Latin-1 vs higher-plane asymmetry, empty and null input, the complete document, verbatiminnerHtml, a real anchor,&in a query string, and a quote inside the URL that would otherwise close thehref.Verified on the pushed tree with the NuGet audit on:
dotnet restore: exit 0.dotnet build -warnaserror: exit 0.UserPasswordServiceTestsorUserRegisteredEmailHandlerTestsrequired a change, the refactor would have altered identity behaviour and stopped being a refactor.Encodeto the four-Replacechain, makingShellencodeinnerHtml, dropping the doctype, and leaving the URL raw in thehrefeach turn the matching test red, with the file restored byte-exact (sha256 checked).Also folded in: the unescaped amount
The earlier version of this description offered to send this separately. It is one line in the same file this PR is already unifying, and the same defect class the nit is about, so it rides here instead.
BillingEmailBodies.InvoiceIssuedinterpolatedamountText— which embedscurrency, a data-driven value — into the markup unencoded. It was the only value in that file reaching markup raw;invoiceNumber,tenantNameandplanwere all escaped.Not a vulnerability today, and I would rather say that than dress it up. I traced both paths that reach this e-mail: the top-up invoice passes a hardcoded
"USD", and the subscription invoice takes the currency from the plan, whereCreatePlanCommandValidatorenforcesNotEmpty().Length(3)behindBillingPermissions.Manage. Three characters, in element content, written by an operator, cannot form a working payload. What makes it worth fixing is that the only thing standing between that value and the markup is a length rule in a different module, which nobody would think to check before relaxing.It is a no-op for every real currency code: encoding
100.00 USDreturns it unchanged, so no delivered e-mail changes.Stated rather than glossed: this line is not pinned by a test.
BillingEmailBodiesisinternalto Notifications, which has no test project — the module'sAssemblyInfo.csalready declaresInternalsVisibleTo("Notifications.Tests")for one that was never created. Adding it here would mean a new project plus asrc/FSH.Starter.slnxentry, turning a six-file refactor into a solution-structure change for a one-line fix. Happy to add the project if you would rather have the coverage than the smaller diff.