- MigraDocExtensions: fix a bug where capping an oversized explicit width+height image re-derived the other dimension from the image's native pixel ratio instead of scaling both sides uniformly, silently distorting a deliberately non-native-ratio (e.g. stretched) image. - InvoiceService: derive the mfr-portal image whitelist prefix from the existing Fds:MFR_host config setting (via MFRClientConfig.RestRoot) instead of a second hardcoded literal, so it can't drift from the actual configured host; parse <img> sources with HtmlAgilityPack (already used by the PDF's own HTML converter) instead of a regex, so a decoy attribute like data-src can't be picked up in place of the real src; dedup fetch URLs with a HashSet instead of O(n^2) List scanning; reuse FuchsPdf.MigraDocFilenameFromByteArray instead of re-spelling the "base64:" prefix. - Fix a stale log message and comment left over from the GenerateInvoicePdf -> GenerateInvoicePdfAsync rename, and update INVOICE_LIFECYCLE.md's references to the old sync method name. - Add tests for the config-derived whitelist, a decoy-attribute src, and the 20-image-per-invoice fetch cap boundary. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
5.2 KiB
status, date, applyTo, supersededBy
| status | date | applyTo | supersededBy | ||||||
|---|---|---|---|---|---|---|---|---|---|
| Accepted | 2026-09-10 |
|
0018 — Invoice-text images are fetched at render time from a domain whitelist, not embedded as data URIs
Context
The invoice line-item description is TinyMCE-authored HTML (FdsInvoiceData/InvoiceBlock.Items
item desc), and can contain <img src="https://portal.mobilefieldreport.com/mfr/..."> picture
links pulled from the mfr ERP — the browser resolves and displays them in the editor. The PDF
pipeline, however, renders through a hand-rolled HTML→MigraDoc subset converter
(MigraDoc.Extensions.Html.HtmlConverter in MigraDocExtensions.cs), which had no img handler
at all: every <img>, remote or embedded, was silently dropped, so images the editor showed never
appeared on the actual invoice.
MigraDoc/PdfSharp does not fetch remote URLs itself, and the converter runs synchronously deep
inside the largely-synchronous FuchsPdf static rendering pipeline. Fetching an arbitrary
user-supplied URL server-side is also an SSRF surface — the invoice text is user/editor-controlled,
so a naive "download whatever src says" would let it reach internal/private network addresses.
Decision
- Invoice images are inlined into the PDF only when their
srcstarts with the mfr REST root (InvoiceService.AllowedInlineImagePrefix, e.g.https://portal.mobilefieldreport.com/mfr/) — the only origin the invoice editor's picture links come from. This is derived from the sameFds:MFR_hostconfiguration entry the MFR REST client itself uses (viaMFR_RESTClient.MFRClientConfig.RestRoot), not a second hardcoded literal, so it can't drift if the configured mfr host ever changes per environment/tenant. Anything else (any other host,http://, a different path) is left unrendered, exactly as before this feature — no generic remote fetch. - The fetch happens once per invoice render, in
InvoiceService.ResolveInlineImagesAsync(async, off a namedIHttpClientFactoryclient"InvoiceInlineImages", 10s timeout), before the synchronous MigraDoc render starts.InvoiceService.GenerateInvoicePdftherefore becameGenerateInvoicePdfAsync— every caller already ran in an async context.<img>sources are found by parsing the description HTML with HtmlAgilityPack (ResolveInlineImagesAsync) — the same library theHtmlConverteritself parses the identical text with — rather than a regex, so a decoy attribute next to the realsrc(e.g.data-src="...") can't be picked up in its place. Each fetched image is capped at 5MB, must report animage/*content-type, and the invoice fetches at most 20 distinct images; failures (unreachable, wrong type, oversized) are logged and just leave that one image out — they never fail the whole render. - The resolved
{src → "base64:..."}map flows down as a plainIReadOnlyDictionary<string,string>?parameter throughIPdfService.ApplyInvoice→FuchsPdf.ApplyInvoice→Cell.AddHtmlinto theHtmlConverter, whose newimghandler only emits an image for asrcpresent in that map — it does no fetching itself and never sees the network. Image sizing mirrors the HTML: the handler readswidth/height(attribute or inlinestyle, as TinyMCE's resize handles write them) and converts CSS px → cm, capped to 7×10cm so a malformed/huge size can't blow out the invoice table layout.
Consequences
- Any future host serving invoice-editor images must be added to the whitelist deliberately (a one-line prefix, or a small allowed-prefixes list if a second origin appears) — this is not meant to grow into a generic "fetch any URL" facility.
IInvoiceService.GenerateInvoicePdf→GenerateInvoicePdfAsync, andIInvoiceDraftService.RenderPreview→RenderPreviewAsync(same signature change, same reason) are breaking signature changes; anything implementing/mocking these interfaces needs updating.- Image src values not covered by the whitelist — including a stray non-mfr remote URL, or a raw
data:URI a user might paste in — still render as nothing, matching the pre-existing (silent) drop behaviour. If embeddeddata:images need support later, that's a separate, simpler decision (no network fetch involved) and can reuse the sameimghandler contract.
Alternatives considered
- Fetch inside the HTML converter itself (sync-over-async
HttpClient.GetAsync(...).Result) — rejected: violates the project's no-sync-over-async rule and keeps SSRF-relevant network code inside a generic, reusable HTML converter instead of a single audited call site. - Fully embed images as
data:base64 URIs at editor save time — rejected: doubles storage size per invoice edit, and the image is already resolved by the mfr portal itself; re-fetching a small number of images once at render time is simpler and keeps the source of truth external. - Allow any remote
http(s)://host — rejected on SSRF grounds; every invoice image the editor can currently produce is from the mfr portal, so a whitelist costs nothing today.