Files
Fuchs_Intranet/Fuchs/Docs/Decisions/0018-invoice-editor-images-inlined-into-pdf.md
T
StefanandClaude Sonnet 5 406c6f2860 Fix invoice-image code-review findings: aspect ratio, whitelist config, stale refs
- 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>
2026-09-10 21:03:09 +02:00

78 lines
5.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
status: Accepted
date: 2026-09-10
applyTo:
- "Fuchs/code/MigraDocExtensions.cs"
- "Fuchs/code/FuchsPdf.cs"
- "Fuchs/Services/InvoiceService.cs"
- "Fuchs/Services/IInvoiceService.cs"
- "Fuchs/Services/IPdfService.cs"
- "Fuchs/Services/FuchsPdfService.cs"
supersededBy: ""
---
# 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 `src` starts 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 same
`Fds:MFR_host` configuration entry the MFR REST client itself uses (via
`MFR_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 named `IHttpClientFactory` client `"InvoiceInlineImages"`, 10s timeout), *before*
the synchronous MigraDoc render starts. `InvoiceService.GenerateInvoicePdf` therefore became
`GenerateInvoicePdfAsync` — every caller already ran in an async context. `<img>` sources are
found by parsing the description HTML with HtmlAgilityPack (`ResolveInlineImagesAsync`) — the
same library the `HtmlConverter` itself parses the identical text with — rather than a regex, so
a decoy attribute next to the real `src` (e.g. `data-src="..."`) can't be picked up in its place.
Each fetched image is capped at 5MB, must report an `image/*` 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 plain `IReadOnlyDictionary<string,string>?`
parameter through `IPdfService.ApplyInvoice` → `FuchsPdf.ApplyInvoice` → `Cell.AddHtml` into the
`HtmlConverter`, whose new `img` handler only emits an image for a `src` present in that map —
it does no fetching itself and never sees the network. Image sizing mirrors the HTML: the
handler reads `width`/`height` (attribute or inline `style`, 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`, and
`IInvoiceDraftService.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 embedded `data:` images need support later, that's a separate, simpler
decision (no network fetch involved) and can reuse the same `img` handler 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.