> For the complete documentation index, see [llms.txt](https://wong-coupon.gitbook.io/flutter/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://wong-coupon.gitbook.io/flutter/my-flutter/ui-media/safe-server-driven-html-flutter.md).

# Rendering Server-Driven HTML Safely in Flutter

How to design bounded ingress, a policy compiler, and typed intents for rendering server-managed HTML without giving network or navigation authority to the widget

## Result

Server-managed HTML works well for notices, guides, terms, or release notes because content can change without another app release. In return, an HTML string contains more than text. It can request a large table, apply styles, load an image, or open a URL.

In the Flutter source I reviewed, a shared HTML renderer is used by several groups of screens. It currently parses a `String` in the build path, installs custom extensions for images, SVG, tables, and lists, and also handles link routing. This is **\[SOURCE-VERIFIED]** for the reviewed snapshot; it does not prove that every payload has the same trust level or that the backend has no sanitizer.

The architecture I use for the public design is:

```
HTTP response byte stream
        │
        ▼
BoundedHtmlSource
actual-byte cap · media type · charset · bounded decoding
        │
        ▼
BoundedHtmlInput
        │
        ▼
SafeHtmlCompiler(policy v1, pure)
tag · attribute · style · URL · DOM/table budgets
        │
        ├── reject ──► readable plain-text fallback
        │
        ▼
SafeHtmlDocument
        │
        ▼
side-effect-free SafeHtmlView
        ├── link tap ──► typed LinkIntent ──► app-owned handler
        └── image ─────► owned bounded loader ──► local image state
```

These boundaries produce five important outcomes:

* The response is limited by actual bytes before decoding or DOM construction.
* The HTML grammar, URL policy, and complexity budget are versioned, reviewed, and tested independently.
* The renderer accepts only a `SafeHtmlDocument`; its primary API never accepts raw HTML.
* A blocked link retains no destination. An accepted link retains only a normalized URI after the entire policy passes.
* A remote image is not automatically allowed to load; network, redirects, decoding, and caching belong to a separate loader.

I use the following evidence qualifiers throughout the article:

| Qualifier              | Meaning                                                                                  |
| ---------------------- | ---------------------------------------------------------------------------------------- |
| **\[SOURCE-VERIFIED]** | Directly verified in source or dependency locks from the research snapshot               |
| **\[CONFIGURED]**      | A configuration or version exists, but runtime behavior is not proven                    |
| **\[TEST-EVIDENCE]**   | A source assertion or command result is described within its actual scope                |
| **\[PROPOSED]**        | A design proposed by this article, not claimed to exist in the source app                |
| **\[UNKNOWN]**         | The corresponding backend, device, plugin, dashboard, or runtime evidence is unavailable |

### Version scope

The research snapshot has the following locks:

| Component              | Reviewed version                     | Evidence level                                                            |
| ---------------------- | ------------------------------------ | ------------------------------------------------------------------------- |
| Flutter                | `3.41.2`                             | **\[CONFIGURED]** through FVM                                             |
| Dart                   | `3.11.0` up to but excluding `4.0.0` | **\[CONFIGURED]**                                                         |
| `flutter_html`         | `3.0.0`                              | **\[SOURCE-VERIFIED]** lock and local package source                      |
| `flutter_html_svg`     | `3.0.0-beta.2`                       | **\[SOURCE-VERIFIED]** lock; the exact plugin source was no longer cached |
| `flutter_layout_grid`  | `2.0.8`                              | **\[SOURCE-VERIFIED]** lock                                               |
| `cached_network_image` | `3.3.1`                              | **\[SOURCE-VERIFIED]** lock                                               |
| `url_launcher`         | `6.3.2`                              | **\[SOURCE-VERIFIED]** lock                                               |
| `http`                 | `1.6.0`                              | **\[SOURCE-VERIFIED]** lock                                               |

A manifest constraint is not the same as the resolved runtime version. When upgrading a parser, renderer, or extension, I review the lockfile diff and rerun the fixture corpus instead of treating the upgrade as an unconditional change.

According to the upstream documentation, `flutter_html` provides `Html.fromDom`, tag restrictions, and extension APIs. Those APIs help establish a compiler/renderer boundary, but they do not turn an app policy into a security guarantee. This article does not claim protection against every XSS case, parser bug, tracking mechanism, or denial of service.

### When to use an HTML renderer

I use a Flutter HTML renderer when content is static rich text: paragraphs, headings, lists, links, controlled images, and small tables. The output remains a Flutter span/widget tree.

If the content is a web application that needs JavaScript, forms, iframes, navigation history, or a bidirectional bridge, WebView is a different boundary and needs its own navigation delegate.

{% content-ref url="/pages/6hDqpC429EsGfW5oXVfG" %}
[TradingView in a Flutter WebView](/flutter/my-flutter/ui-media/tradingview-webview-javascript-bridge.md)
{% endcontent-ref %}

## Problem

A short anti-pattern often looks harmless:

```dart
Widget renderArticle(String responseBody) {
  return Html(
    data: responseBody,
    onLinkTap: (url, _, __) => launchUrl(Uri.parse(url!)),
  );
}
```

The problem is not limited to `<script>`. This code gives several capabilities to an unclassified input:

* The `String` has already been materialized before the renderer can limit network or memory acquisition.
* The parser may receive a document with too many bytes, too much depth, or too many nodes.
* Attributes and inline styles can break layout or hide content.
* An `<img>` can trigger a network request to an unexpected origin.
* A link can go directly to a browser, WebView, app route, or OS handler.
* Invalid `rowspan` or `colspan` values can make grid placement expensive or invalid.
* A provider or parser failure can remove the entire content surface without an error boundary.

### What the current source proves

**\[SOURCE-VERIFIED]** The renderer uses `flutter_html` to parse a raw string into a DOM and then create Flutter spans and widgets. Local source for the locked version shows that extensions are queried before built-ins, and the first matching extension owns the node. The package also parses inline styles and `<style>` content. The reviewed wrapper does not pass an explicit tag allowlist.

This supports saying that client policy is not centralized in the wrapper. It does not support concluding that the backend performs no validation or that every CSS property is executed.

**\[SOURCE-VERIFIED]** The custom image extension receives every `<img>` with a non-empty `src` before the SVG extension and passes its URL to a network image path. The wrapper has no explicit scheme/exact-origin allowlist, byte or pixel cap, redirect revalidation, error placeholder, or `alt` semantics.

**\[SOURCE-VERIFIED]** The image viewer is another capability: it resolves the image, displays it full screen, and exposes a download path. The same image reference can therefore touch an inline provider, a viewer provider, and an HTTP download. Do not assume the cache coalesces every request or that a rendered URL is automatically allowed to be downloaded.

**\[CONFIGURED]** An SVG extension exists in the dependency graph. **\[UNKNOWN]** This research did not verify the exact beta plugin behavior for networking, external references, malformed SVG, or parser limits. SVG is therefore disabled by default in the public design.

**\[SOURCE-VERIFIED]** The table extension computes its own grid, supports `rowspan` and `colspan`, removes some percentage dimensions, and then uses equal-width tracks. Existing tests prove that the table does not collapse in three fixtures, but they do not prove correct percentage ratios, malformed-span behavior, semantics, or RTL.

**\[SOURCE-VERIFIED]** The span parser does not explicitly clamp positive minimum and maximum values. The grid also does not assign explicit table, row, header, or cell semantics.

**\[SOURCE-VERIFIED]** The current `maxLines` contract adds a selector that matches no element according to the reviewed package source. There is no direct widget test for this behavior. I do not extend that finding to every package version; the public design separates previews from full rich documents.

**\[SOURCE-VERIFIED]** The default link callback rewrites schemes, records an event, branches into deep links, and opens either a dedicated page or a WebView. Some predicates use string-prefix matching. A caller can also replace the callback and invoke navigation directly. The renderer therefore owns too much routing policy, and that policy can be bypassed.

**\[UNKNOWN]** There is no evidence for post-launch redirects, every WebView's final navigation delegate, the selected OS handler, destination authorization, or readiness at every callsite.

### Every side effect needs an owner

I separate the ability to request an action from the authority to execute it:

```
SafeHtmlView
    │
    ├── tap link ──► LinkIntent
    │                  ├── AppLinkCandidate ─► deep-link coordinator
    │                  ├── ExternalWebLink ──► browser owner
    │                  ├── InAppWebLink ─────► WebView owner
    │                  └── BlockedLink ──────► accessible local feedback
    │
    └── image node ─► RemoteImagePolicy
                       ├── blocked ──────────► placeholder + alt
                       ├── tap-to-load ──────► user consent
                       └── allowed ──────────► bounded image loader
                                                └── SafeImageRef
                                                     └── viewer owner
```

`LinkIntent` is a classified request, not authorization. The deep-link coordinator still owns readiness, authentication, and destination policy.

{% content-ref url="/pages/XuzrnRXJWWpiiB1XhH1m" %}
[Multi-Source Deep Links in Flutter](/flutter/my-flutter/systems-realtime/deep-link-app-links-adjust-onesignal.md)
{% endcontent-ref %}

The WebView owner must revalidate navigation and redirects against its own policy; the HTML renderer must not transfer trust to the WebView.

### Scattered failure handling is not a safe fallback

The source has several local fallbacks: an empty image becomes an empty widget, link exceptions are logged, a missing table cell displays error text, and downloads return a generic message. **\[SOURCE-VERIFIED]** There is no outer boundary that converts parser or build failures into readable content.

A safe fallback distinguishes these cases:

* Invalid block: replace only that block with a placeholder or plain text.
* Document over budget: reject the entire document before widget construction.
* Image or provider failure: replace only the image node instead of throwing through the whole document.
* Blocked link: retain a safe visible label, discard the destination, and provide accessible feedback.
* Telemetry: accept only typed codes and count buckets, never raw HTML, URLs, labels, or exception text.

## Solution

The entire design from this point is **\[PROPOSED]**. Numeric limits are illustrative starting points; production values must be tuned using real payloads and device measurements.

### 1. Bound the response before decoding and parsing

`compile(String raw)` can only reject before DOM parsing. It cannot undo allocation or network acquisition that has already happened. The primary API must begin with a response lease or stream owned by the ingress adapter.

```dart
enum HtmlIngressRejectCode {
  transferTooLarge,
  decodedBodyTooLarge,
  unsupportedMediaType,
  unsupportedCharset,
  invalidEncoding,
  transportFailure,
  cancelled,
}

final class HtmlResponseMetadata {
  const HtmlResponseMetadata({
    required this.mediaType,
    required this.charset,
    required this.contentEncoding,
    this.contentLength,
  });

  final String? mediaType;
  final String? charset;
  final String? contentEncoding;
  final int? contentLength;
}

final class HtmlIngressPolicy {
  const HtmlIngressPolicy({
    required this.allowedMediaTypes,
    required this.allowedCharsets,
    required this.maxTransferBytes,
    required this.maxDecodedBytes,
  });

  final Set<String> allowedMediaTypes;
  final Set<String> allowedCharsets;
  final int maxTransferBytes;
  final int maxDecodedBytes;
}

final class ActualByteCounter {
  ActualByteCounter(this.limit);

  final int limit;
  int _seen = 0;

  int get seen => _seen;

  bool accept(List<int> chunk) {
    _seen += chunk.length;
    return _seen <= limit;
  }
}

final class BoundedHtmlInput {
  const BoundedHtmlInput._({
    required this.text,
    required this.transferBytes,
    required this.decodedBytes,
    required this.mediaType,
  });

  final String text;
  final int transferBytes;
  final int decodedBytes;
  final String mediaType;
}

sealed class HtmlIngressResult {
  const HtmlIngressResult();
}

final class HtmlIngressAccepted extends HtmlIngressResult {
  const HtmlIngressAccepted(this.input);
  final BoundedHtmlInput input;
}

final class HtmlIngressRejected extends HtmlIngressResult {
  const HtmlIngressRejected(this.code);
  final HtmlIngressRejectCode code;
}

abstract interface class HtmlResponseLease {
  HtmlResponseMetadata get metadata;
  Stream<List<int>> get transferBody;
  Future<void> close();
}

abstract interface class BoundedHtmlSource {
  Future<HtmlIngressResult> read(
    HtmlResponseLease response, {
    required HtmlIngressPolicy policy,
  });
}
```

Ingress proceeds in this order:

1. Normalize and then validate the media type and charset.
2. Use `Content-Length` only for early rejection when the header already exceeds the cap; never use it to accept a body.
3. Count every actual transfer-stream chunk against `maxTransferBytes`, including missing, false, or chunked length cases.
4. If content encoding is present, decode with an owned bounded decoder and keep counting output against `maxDecodedBytes`.
5. Decode the charset strictly; the sample accepts only UTF-8 and rejects malformed bytes.
6. Create `BoundedHtmlInput` only after every check passes.
7. On overflow, timeout, or disposal, cancel the subscription and close the response lease immediately.

[`ByteStream`](https://pub.dev/documentation/http/latest/http/ByteStream-class.html) represents a response body as a stream of byte chunks. [`Utf8Decoder`](https://api.dart.dev/dart-convert/Utf8Decoder-class.html) supports strict decoding instead of silently replacing malformed sequences.

If the HTTP stack decompresses before exposing the stream, the adapter must still cap the decompressed stream and explicitly state that original transfer bytes are no longer observable. To enforce both layers, the owner must disable automatic decompression or use a transport adapter that exposes the raw transfer stream.

A caller that already has a `String` must still measure its UTF-8 bytes and reject it before DOM parsing. This boundary protects the parser, but it does not claim to protect an allocation that has already occurred.

### 2. Compile with a versioned policy

The compiler is a pure function: it opens no network connection, performs no navigation, reads no headers, and does not depend on `BuildContext`.

```dart
enum HtmlIssuePhase { parse, policy, layout }

enum HtmlIssueCode {
  tagDropped,
  attributeDropped,
  styleDropped,
  linkBlocked,
  imageBlocked,
  tableRejected,
  budgetApproachingLimit,
}

enum HtmlRejectCode {
  inputTooLarge,
  malformedDocument,
  complexityExceeded,
  unsupportedContentProfile,
}

final class HtmlBudget {
  const HtmlBudget({
    required this.maxInputBytes,
    required this.maxNodes,
    required this.maxDepth,
    required this.maxTextCharacters,
    required this.maxAttributesPerNode,
    required this.maxTableCells,
    required this.maxColumns,
    required this.maxSpan,
  });

  final int maxInputBytes;
  final int maxNodes;
  final int maxDepth;
  final int maxTextCharacters;
  final int maxAttributesPerNode;
  final int maxTableCells;
  final int maxColumns;
  final int maxSpan;
}

final class SafeHtmlPolicy {
  const SafeHtmlPolicy({
    required this.version,
    required this.allowedTags,
    required this.allowedAttributes,
    required this.allowedClasses,
    required this.allowedInlineStyles,
    required this.linkPolicy,
    required this.imagePolicy,
    required this.budget,
  });

  final int version;
  final Set<String> allowedTags;
  final Map<String, Set<String>> allowedAttributes;
  final Set<String> allowedClasses;
  final Set<String> allowedInlineStyles;
  final LinkPolicy linkPolicy;
  final RemoteImagePolicy imagePolicy;
  final HtmlBudget budget;
}

final class HtmlIssue {
  const HtmlIssue({required this.phase, required this.code});
  final HtmlIssuePhase phase;
  final HtmlIssueCode code;
}

sealed class SafeHtmlDocument {
  const SafeHtmlDocument();
}

sealed class HtmlCompileResult {
  const HtmlCompileResult();
}

final class HtmlAccepted extends HtmlCompileResult {
  const HtmlAccepted(this.document, this.issues);
  final SafeHtmlDocument document;
  final List<HtmlIssue> issues;
}

final class HtmlRejected extends HtmlCompileResult {
  const HtmlRejected(this.fallbackText, this.code);
  final String fallbackText;
  final HtmlRejectCode code;
}

abstract interface class SafeHtmlCompiler {
  HtmlCompileResult compile(
    BoundedHtmlInput input, {
    required SafeHtmlPolicy policy,
  });
}
```

`HtmlIssue` and rejection codes form a typed allowlist. They retain no raw tag names, attributes or values, URLs, visible content, parser messages, or exception strings. `fallbackText` is local presentation data and must not enter telemetry.

A default policy can start with:

| Surface       | Proposed allowlist                                                                                                  | Explicit deny/fallback                                                                                            |
| ------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| Tags          | `p`, `br`, `strong`, `em`, small headings, `ul`, `ol`, `li`, `a`, `blockquote`, `code`, and a table subset          | Drop active script/style/form/iframe/media subtrees or reject them according to the profile                       |
| Attributes    | `href` on `a`; `src`, `alt`, and bounded dimensions on `img`; span/headers on cells                                 | Drop `on*`, arbitrary `data-*`, `target`, `srcset`, and unknown attributes                                        |
| Inline styles | Text alignment, bounded font weight/style/decoration, semantic color tokens, and controlled table percentage widths | Reject `url(...)`, positioning, transforms, animation, hidden display, external fonts, and arbitrary size/spacing |
| URL           | Explicit scheme, capability, exact origin, and path/query/fragment profile                                          | Block relative, unknown, malformed, or user-info URLs unless the profile has a specific rule                      |
| SVG           | Disabled                                                                                                            | Enable only after exact-version review and byte/node/path/network tests                                           |

An unknown passive formatting tag may be unwrapped while preserving text. An active or network-capable tag should have its subtree dropped or its block rejected. This decision must be fixed by content-profile version so the backend and app roll out the same grammar.

### 3. Create accepted links only after complete normalization

Do not use `Set<String> hosts` or `startsWith()`. An origin needs a validated value with structural equality that outside code cannot create through a public constructor.

```dart
enum LinkCapability { appLink, externalWeb, inAppWeb, email, phone }

enum UnknownQueryPolicy { block, drop }

enum FragmentPolicy { block, drop, allowlisted }

enum LinkCategory { appLink, web, email, phone, unknown }

enum LinkBlockCode {
  malformed,
  unsupportedScheme,
  ambiguousCapability,
  originNotAllowed,
  userInfoNotAllowed,
  portNotAllowed,
  pathNotAllowed,
  queryNotAllowed,
  fragmentNotAllowed,
  tooLong,
}

enum TrailingDotPolicy { reject, stripOne }

enum IpLiteralPolicy { reject, allowExactOrigin }

enum NormalizedHostKind { dns, ipv4, ipv6 }

final class HostCanonicalizationPolicy {
  const HostCanonicalizationPolicy({
    required this.trailingDotPolicy,
    required this.ipLiteralPolicy,
  });

  final TrailingDotPolicy trailingDotPolicy;
  final IpLiteralPolicy ipLiteralPolicy;
}

final class NormalizedHost {
  const NormalizedHost._(this.ascii, this.kind);

  final String ascii;
  final NormalizedHostKind kind;

  @override
  bool operator ==(Object other) {
    return other is NormalizedHost &&
        other.ascii == ascii &&
        other.kind == kind;
  }

  @override
  int get hashCode => Object.hash(ascii, kind);
}

abstract interface class HostCanonicalizer {
  NormalizedHost? canonicalize(
    Uri uri, {
    required HostCanonicalizationPolicy policy,
  });
}

final class ValidatedOrigin {
  const ValidatedOrigin._(this.scheme, this.host, this.effectivePort);

  static ValidatedOrigin? tryFromUri(
    Uri uri, {
    required Map<String, int> defaultPorts,
    required HostCanonicalizer hostCanonicalizer,
    required HostCanonicalizationPolicy hostPolicy,
  }) {
    if (uri.scheme.isEmpty || uri.host.isEmpty || uri.userInfo.isNotEmpty) {
      return null;
    }

    final scheme = uri.scheme.toLowerCase();
    final host = hostCanonicalizer.canonicalize(uri, policy: hostPolicy);
    final effectivePort = uri.hasPort ? uri.port : defaultPorts[scheme];
    if (host == null ||
        effectivePort == null ||
        effectivePort < 1 ||
        effectivePort > 65535) {
      return null;
    }

    return ValidatedOrigin._(scheme, host, effectivePort);
  }

  final String scheme;
  final NormalizedHost host;
  final int effectivePort;

  @override
  bool operator ==(Object other) {
    return other is ValidatedOrigin &&
        other.scheme == scheme &&
        other.host == host &&
        other.effectivePort == effectivePort;
  }

  @override
  int get hashCode => Object.hash(scheme, host, effectivePort);
}

final class LinkCapabilityPolicy {
  const LinkCapabilityPolicy({
    required this.capability,
    required this.allowedSchemes,
    required this.origins,
    required this.allowedPaths,
    required this.allowedQueryValues,
    required this.unknownQueryPolicy,
    required this.fragmentPolicy,
    required this.allowedFragment,
  });

  final LinkCapability capability;
  final Set<String> allowedSchemes;
  final Set<ValidatedOrigin> origins;
  final List<RegExp> allowedPaths;
  final Map<String, RegExp> allowedQueryValues;
  final UnknownQueryPolicy unknownQueryPolicy;
  final FragmentPolicy fragmentPolicy;
  final RegExp? allowedFragment;
}

final class LinkPolicy {
  const LinkPolicy({
    required this.defaultPorts,
    required this.hostCanonicalization,
    required this.byCapability,
  });

  final Map<String, int> defaultPorts;
  final HostCanonicalizationPolicy hostCanonicalization;
  final Map<LinkCapability, LinkCapabilityPolicy> byCapability;
}

final class NormalizedLinkTarget {
  const NormalizedLinkTarget._(this.uri, this.capability);
  final Uri uri;
  final LinkCapability capability;
}

sealed class LinkIntent {
  const LinkIntent();
}

sealed class AcceptedLinkIntent extends LinkIntent {
  const AcceptedLinkIntent(this.target);
  final NormalizedLinkTarget target;
}

final class AppLinkCandidate extends AcceptedLinkIntent {
  const AppLinkCandidate(super.target);
}

final class ExternalWebLink extends AcceptedLinkIntent {
  const ExternalWebLink(super.target);
}

final class InAppWebLink extends AcceptedLinkIntent {
  const InAppWebLink(super.target);
}

final class EmailLink extends AcceptedLinkIntent {
  const EmailLink(super.target);
}

final class PhoneLink extends AcceptedLinkIntent {
  const PhoneLink(super.target);
}

AcceptedLinkIntent acceptedIntentFor(NormalizedLinkTarget target) {
  return switch (target.capability) {
    LinkCapability.appLink => AppLinkCandidate(target),
    LinkCapability.externalWeb => ExternalWebLink(target),
    LinkCapability.inAppWeb => InAppWebLink(target),
    LinkCapability.email => EmailLink(target),
    LinkCapability.phone => PhoneLink(target),
  };
}

final class BlockedLink extends LinkIntent {
  const BlockedLink({
    required this.code,
    required this.category,
    required this.safeLabel,
  });

  final LinkBlockCode code;
  final LinkCategory category;
  final String safeLabel;
}

abstract interface class LinkClassifier {
  LinkIntent classify({
    required String rawHref,
    required String visibleLabel,
    required LinkPolicy policy,
  });
}
```

`NormalizedHost._` and `ValidatedOrigin._` prevent code outside the policy library from forging an unnormalized host or origin. `HostCanonicalizer` is an independently tested implementation, not scattered `toLowerCase()` calls. It must:

* Lowercase and convert a Unicode host to canonical ASCII/IDNA under a defined profile and version; equivalent Unicode and punycode inputs must produce the same `NormalizedHost`.
* Apply an explicit `TrailingDotPolicy`: reject or strip exactly one root dot, without silently changing policy between callers.
* Reject empty labels, invalid or ambiguous IDNA, percent-encoded hosts, Unicode dot separators that cannot be canonicalized, and hosts that retain escapes or control characters.
* Classify DNS, canonical IPv4, and canonical IPv6. Reject IP literals by default; use `allowExactOrigin` only when the profile requires it and the exact origin is allowlisted. Reject shortened/octal/hex IPv4, IPv6 zone identifiers, and ambiguous representations.

The classifier parses with `Uri.tryParse`, blocks user-info including percent-encoded forms before host canonicalization, and then computes the effective port. An omitted HTTPS port and an explicit default port must produce the same origin; a non-default port needs its own allowlist entry. Exact-origin comparison covers `scheme + NormalizedHost + effectivePort`, so a suffix host cannot match.

`LinkPolicy.byCapability` keeps separate policies for `appLink`, `externalWeb`, `inAppWeb`, `email`, and `phone`. Because several capabilities may accept the `https` scheme, the classifier does not perform a single scheme lookup and does not use first-match behavior. It evaluates **all** candidate policies against the normalized scheme, exact origin, path, query, and fragment contract:

1. With no full match, return `BlockedLink` with a typed reason.
2. With exactly one full match, rebuild the canonical URI, create `NormalizedLinkTarget._`, and map it to a subtype with an exhaustive `switch`.
3. With two or more full matches, return `ambiguousCapability`; never use registry order to select a broader policy.

Two capabilities may share an HTTPS origin when their path contracts do not overlap, such as separate app-link and in-app article paths. An overlap is a policy configuration error that must fail closed and have a regression test.

Only after the exact origin passes does the classifier validate the path, every query key/value, and the fragment:

* Unknown query keys are blocked or dropped according to the profile, never silently forwarded.
* The query is rebuilt with canonical encoding and ordering after validation.
* Fragments are blocked by default; a profile may drop them or apply a grammar and length allowlist.
* App links, external web links, and in-app web links have separate capability policies even when they share an origin.
* `mailto` and `tel` use dedicated validators rather than pretending to be web origins.
* `javascript`, `data`, `file`, `content`, `intent`, HTTP by default, and unknown schemes are blocked.

Only the classifier in the same library creates `NormalizedLinkTarget._`. `acceptedIntentFor()` uses an exhaustive switch to create the correct subtype instead of letting the renderer cast capabilities. `BlockedLink` has no URI or destination field; raw `href` is temporary and must disappear after compilation. `safeLabel` is length-capped and stripped of control characters; when visible text is URL-shaped, equals or contains `href`, or includes a query, use an app-owned generic label instead. Telemetry also receives no label, raw URI, normalized URI, origin, path, query, or fragment.

The renderer emits the intent exactly once. An external handler decides the browser, WebView, app route, readiness, and authorization. The owner must parse and revalidate every redirect; an allow decision for the first URL does not carry over to the next `Location`.

### 4. Keep the renderer free of side effects

```dart
Widget buildSafeHtml(HtmlAccepted compiled, SafeHtmlController controller) {
  return SafeHtmlView(
    document: compiled.document,
    onLinkIntent: controller.handleLinkIntent,
    onImageIntent: controller.handleImageIntent,
  );
}
```

`SafeHtmlView` imports no navigator, route table, URL launcher, HTTP client, or WebView package. It only:

* Maps typed document nodes to Flutter widgets and spans.
* Resolves semantic typography and color tokens from `BuildContext` without mutating caller-owned style objects.
* Emits typed intents when the user taps.
* Isolates each block failure and renders a local fallback.

Light and dark themes should resolve through the app's semantic tokens, not arbitrary CSS colors from the payload.

{% content-ref url="/pages/DDwBamhA1zlGZaY4h0sH" %}
[Dark Mode](/flutter/my-flutter/ui-media/dark-mode.md)
{% endcontent-ref %}

### 5. Load remote images through an owned bounded loader

The default is `blocked` or `tapToLoad`. Only a reviewed profile should use `trustedOriginsAutomatic` for exact HTTPS origins.

```dart
enum RemoteImageMode { blocked, tapToLoad, trustedOriginsAutomatic }

enum ImageLoadRejectCode {
  malformedTarget,
  originNotAllowed,
  redirectNotAllowed,
  redirectLoop,
  tooManyRedirects,
  statusNotAllowed,
  mediaTypeNotAllowed,
  streamedBodyTooLarge,
  decodedBodyTooLarge,
  dimensionsUnavailable,
  pixelBudgetExceeded,
  frameBudgetExceeded,
  decodeFailed,
  cancelled,
  timedOut,
}

enum ImageCacheMode { disabled, memoryOnly, reviewedPersistent }

final class RemoteImagePolicy {
  const RemoteImagePolicy({
    required this.mode,
    required this.allowedOrigins,
    required this.allowedMediaTypes,
    required this.maxBytesPerHop,
    required this.maxTotalBytes,
    required this.maxRedirectHops,
    required this.maxPixelCount,
    required this.maxFrameCount,
    required this.timeout,
    required this.cacheMode,
  });

  final RemoteImageMode mode;
  final Set<ValidatedOrigin> allowedOrigins;
  final Set<String> allowedMediaTypes;
  final int maxBytesPerHop;
  final int maxTotalBytes;
  final int maxRedirectHops;
  final int maxPixelCount;
  final int maxFrameCount;
  final Duration timeout;
  final ImageCacheMode cacheMode;
}

final class NormalizedImageTarget {
  const NormalizedImageTarget._(this.uri, this.origin);
  final Uri uri;
  final ValidatedOrigin origin;
}

final class SafeImageRef {
  const SafeImageRef._(this.cacheKey);
  final String cacheKey;
}

sealed class ImageLoadResult {
  const ImageLoadResult();
}

final class ImageLoadAccepted extends ImageLoadResult {
  const ImageLoadAccepted(this.image);
  final SafeImageRef image;
}

final class ImageLoadRejected extends ImageLoadResult {
  const ImageLoadRejected(this.code);
  final ImageLoadRejectCode code;
}

abstract interface class ImageLoadCancellation {
  bool get isCancelled;
}

abstract interface class ImageHopLease {
  Uri get responseUri;
  int get statusCode;
  String? get mediaType;
  int? get contentLength;
  Stream<List<int>> get transferBody;
  Future<void> close();
}

abstract interface class BoundedImageLoader {
  Future<ImageLoadResult> load(
    NormalizedImageTarget imageTarget, {
    required RemoteImagePolicy policy,
    required ImageLoadCancellation cancellation,
  });
}
```

The compiler creates `NormalizedImageTarget._` only after the image scheme, exact origin, path, and query policies pass; this target is separate from link capabilities. The loader creates `SafeImageRef._` only after fetch and decode checks pass, so the viewer cannot receive raw `src` or forge a trusted reference.

The loader disables automatic redirects and processes each hop itself:

1. Revalidate the exact normalized origin at every URL.
2. Classify status and media-type rules at every hop. A redirect response is never accepted or decoded as an image; the terminal response needs an allowed success status and media type.
3. Use a visited set and redirect-hop cap to stop loops.
4. Count actual chunks against both per-hop and whole-chain budgets. Missing, false, or chunked `Content-Length` does not change enforcement.
5. When content encoding is present, cap both the transfer stream and post-decompression output when the transport permits it.
6. Cancel and close immediately on overflow, timeout, or widget disposal.
7. After bounding the bytes, inspect header, dimensions, and frame metadata before full-resolution decoding when the API supports it.
8. If safe inspection is unavailable, fail closed or use an owned decoder with target downsampling; never feed arbitrary bytes to a generic provider and check pixels afterward.

[`instantiateImageCodec`](https://api.flutter.dev/flutter/dart-ui/instantiateImageCodec.html) exposes target dimensions for decoding, and the current API directs callers that need decode control toward `ImageDescriptor`. This supports owner-controlled decode sizing; it does not prove that a generic network provider enforces a pixel budget.

The loader sends no authentication header, cookie, or app/session identifier to an origin obtained from HTML. Cache retention must be explicit. Every failure replaces only the image node with an `alt`-aware placeholder and retry state; the rest of the document keeps rendering.

Tapping an image emits an intent containing `SafeImageRef`, never raw `src`. Viewing and downloading are separate capabilities and must reuse the owned loader and policy instead of accepting a raw URL.

### 6. Disable SVG, normalize tables, and separate previews

SVG is disabled by default. If a product needs SVG, I enable it only after pinning the exact plugin and parser versions and testing bytes, dimensions, node/path complexity, external references, malformed input, timeouts, and raster fallback. **\[UNKNOWN]** These behaviors were not verified for the exact beta extension in the snapshot.

A table should not be laid out directly from `rowspan` and `colspan` strings. The compiler must:

1. Parse and clamp positive spans under the policy.
2. Build a deterministic occupancy matrix.
3. Reject overlaps, out-of-bounds placement, nested tables, or cell counts over budget.
4. Normalize percentage widths into bounded column weights; never treat `%` as pixels.
5. Select horizontal scrolling from constraints and the column threshold.
6. Assign table, row, column-header, and cell semantics.

```dart
enum HtmlCellRole { data, rowHeader, columnHeader }

final class HtmlTableCell {
  const HtmlTableCell({
    required this.row,
    required this.column,
    required this.rowSpan,
    required this.columnSpan,
    required this.role,
    required this.content,
  });

  final int row;
  final int column;
  final int rowSpan;
  final int columnSpan;
  final HtmlCellRole role;
  final SafeHtmlDocument content;
}

final class HtmlTableModel {
  const HtmlTableModel({
    required this.rowCount,
    required this.columnCount,
    required this.columnWeights,
    required this.cells,
  });

  final int rowCount;
  final int columnCount;
  final List<double> columnWeights;
  final List<HtmlTableCell> cells;
}

final class TextPreview {
  const TextPreview({required this.maxLines, required this.expandable});
  final int maxLines;
  final bool expandable;
}
```

A percentage-table test must assert relative geometry, not merely that the table is wider than a threshold. `rowspan` and `colspan` tests must cover zero, negative, excessive, overlapping, and missing-cell cases.

A preview is a text-only projection of the compiled document. It builds no image or table providers and creates no network intent. Truncation must be grapheme-safe, with clear ellipsis, expand action, and semantics. A full document does not use a global `maxLines` style that cuts through `WidgetSpan` content.

Narrow and wide layouts, including horizontal scrolling, belong to the responsive contract.

{% content-ref url="/pages/CTKWji67N7nUi8hjGxHc" %}
[Responsive](/flutter/my-flutter/ui-media/responsive.md)
{% endcontent-ref %}

### 7. Make budgets and accessibility release gates

The following illustrative limits must be tuned with privacy-safe telemetry and device benchmarks:

| Budget                       | Starting value | Failure behavior                              |
| ---------------------------- | -------------: | --------------------------------------------- |
| HTML transfer bytes          |       `64 KiB` | Cancel and close the ingress stream           |
| HTML decoded bytes           |       `64 KiB` | Reject before charset decoding or DOM parsing |
| DOM nodes                    |        `2,000` | Reject the document                           |
| DOM depth                    |           `32` | Reject the document                           |
| Text characters              |      `100,000` | Reject or switch to a plain-text profile      |
| Attributes per node          |           `16` | Drop or reject according to the profile       |
| Table cells                  |          `500` | Local table fallback                          |
| Columns                      |           `12` | Local fallback or bounded scrolling           |
| Row/column span              |           `20` | Reject the table block                        |
| Image bytes per hop          |        `5 MiB` | Cancel the response                           |
| Image bytes across the chain |        `6 MiB` | Cancel the redirect chain                     |
| Redirect hops                |            `5` | Typed rejection                               |
| Decoded image pixels         |        `16 MP` | Fail closed or downsample                     |
| Animated frames              |           `60` | Static fallback or rejection                  |

The minimum accessibility matrix is:

* Text scale `1.0`, `1.3`, and `2.0`, without resetting the scaler in a subtree.
* Links expose role, action, label, and blocked feedback.
* Images expose `alt`, loading/error semantics, and never read a raw URL aloud.
* Lists preserve marker and order in the semantics tree.
* Tables expose table, row, header, and cell roles with a sensible reading order.
* Fallbacks remain readable with a screen reader.
* Light and dark tokens preserve contrast in both themes.

Flutter provides [`SemanticsRole.table`, `row`, `cell`, and `columnHeader`](https://api.flutter.dev/flutter/dart-ui/SemanticsRole.html), but source-level roles do not replace device QA. TalkBack and VoiceOver behavior remains **\[UNKNOWN]** for this snapshot.

### 8. Test the policy as a compiler and the renderer as a view

| Test layer          | Required cases                                                                                                                                                                                         |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| HTML ingress        | Missing/false `Content-Length`, chunked overflow, decompression expansion, wrong media/charset, malformed UTF-8, cancel/close                                                                          |
| Tag/attribute/style | Allow, unwrap/drop, active-subtree rejection, event attributes, URL styles, and hidden content                                                                                                         |
| Host/origin         | Unicode/punycode equivalence, case, trailing-dot reject/strip, suffix hosts, empty/ambiguous labels, percent-encoded host/user-info, allowed and blocked IPv4/IPv6 profiles, default/non-default ports |
| Capability registry | The same HTTPS origin with distinct app-link/in-app/external path contracts; correct subtype when unique; overlapping full matches return `ambiguousCapability`, never first-match                     |
| Link                | Unknown queries carrying fake sensitive values, fragment block/drop/allow, malformed encoding, blocked intents retaining no destination, and URL-shaped labels becoming generic labels                 |
| Redirect owner      | Allowed-to-blocked, malformed `Location`, loop, hop limit; revalidate policy at every hop                                                                                                              |
| Typed diagnostics   | Issues, rejections, and telemetry contain no raw tag, attribute, URL, label, or error text                                                                                                             |
| Image loader        | Missing/false length, chunked oversize, wrong status/media, decompression/pixel/frame bombs, timeout/disposal, and provider-failure local fallback                                                     |
| Table               | Grid occupancy, 1/3/5/12 columns, valid/invalid spans, percentage geometry, scroll threshold, and semantics                                                                                            |
| Preview             | Grapheme-safe ellipsis and expansion without creating image/table network intents                                                                                                                      |
| Widget              | Exactly one link intent, block-error isolation, text scaling, and the semantics tree                                                                                                                   |
| Golden              | Light/dark, narrow/regular, scale 1.0/2.0, table spans, blocked/loading/error images, and fallback                                                                                                     |
| Device              | Browser/OS launch failure, deep-link readiness/auth, WebView redirects, TalkBack/VoiceOver, and low-tier timing                                                                                        |

Widget and golden tests use a fake loader plus pinned viewport, DPR, locale, direction, theme, fonts, and animations. They never call a production network. Golden review is behavior and visual review; do not use `--update-goldens` to hide a regression.

{% content-ref url="/pages/OXzEEJ7gphdUcYxkfusW" %}
[Widget Tests and Golden Regression](/flutter/my-flutter/quality-delivery/widget-test-golden-regression.md)
{% endcontent-ref %}

### Test evidence and runtime limits

**\[TEST-EVIDENCE]** The source has three table widget tests, three unit tests for an HTML rewrite helper, and three image-viewer toolbar widget tests. Table assertions prove that the fixtures do not collapse, but they do not verify exact percentage ratios, spans, malformed input, semantics, or goldens. Viewer tests do not cover fetching, status, media type, redirects, privacy, byte/pixel budgets, or error states.

Focused test execution in the research environment stopped before assertions:

* One run had dependency resolution blocked by host-key verification for a private Git dependency.
* The `--no-pub` run stopped during compilation and loading because a test dependency and several local plugin caches were missing.
* These results count as neither a behavioral pass nor a behavioral failure.

The following areas remain **\[UNKNOWN]**:

* Existing backend sanitizers, CMS validation, WAF, and content-profile rollout.
* The trust level and producer of every callsite.
* Exact SVG beta parser and network behavior.
* End-to-end parser/renderer protection and every XSS or runtime-security claim.
* Redirects and final OS/browser/WebView navigation on devices.
* On-device image caching, privacy, actual bytes, decompression, and pixel behavior.
* TalkBack and VoiceOver reading order.
* Parse, compile, and build p50/p95/p99, CPU, memory, and frame jank.
* Production crash, fallback, and latency dashboards.

A reasonable release gate requires the compiler corpus and deterministic widget/golden suites to pass, the exact dependency diff to be reviewed, device accessibility/navigation smoke tests to pass, and performance budgets to be measured separately for ingress, decompression, decoding, compilation, and building.

### References

* [`flutter_html` README](https://github.com/Sub6Resources/flutter_html/blob/master/README.md) and [extension guide](https://github.com/Sub6Resources/flutter_html/wiki/How-To-Use-Extensions)
* [`flutter_html` 3.0 migration guide](https://github.com/Sub6Resources/flutter_html/wiki/Migration-Guides#300)
* Dart [`Uri.host`](https://api.dart.dev/dart-core/Uri/host.html) and [`Uri.tryParse`](https://api.dart.dev/dart-core/Uri/tryParse.html)
* Unicode [UTS #46 — Unicode IDNA Compatibility Processing](https://unicode.org/reports/tr46/)
* IETF [RFC 3986 — URI generic syntax](https://www.rfc-editor.org/rfc/rfc3986) and [RFC 5952 — canonical IPv6 text](https://www.rfc-editor.org/rfc/rfc5952)
* Flutter [`SemanticsRole`](https://api.flutter.dev/flutter/dart-ui/SemanticsRole.html) and [accessibility overview](https://docs.flutter.dev/ui/accessibility)
* Flutter [`matchesGoldenFile`](https://api.flutter.dev/flutter/flutter_test/matchesGoldenFile.html)
* Flutter [`instantiateImageCodec`](https://api.flutter.dev/flutter/dart-ui/instantiateImageCodec.html)
* HTTP [`ByteStream`](https://pub.dev/documentation/http/latest/http/ByteStream-class.html) and Dart [`Utf8Decoder`](https://api.dart.dev/dart-convert/Utf8Decoder-class.html)
* OWASP [Cross-Site Scripting Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html)

## Conclusion

Controlled rendering of server-driven HTML does not begin at the widget. It begins at bounded ingress: count actual bytes, validate media type and charset, and decode within budget. A pure compiler then applies a versioned policy to produce either `SafeHtmlDocument` or a readable fallback.

The renderer handles presentation only. A link tap becomes a typed intent; a blocked intent retains no destination, while an accepted target exists only after exact normalized origin, path, query, and fragment validation. Navigation, deep links, and WebView belong to external owners. Remote images pass through an owned bounded loader, revalidate every redirect hop, and fail locally. SVG is disabled by default.

This design does not replace backend validation, security review, device accessibility testing, or performance measurement. It makes ownership boundaries explicit enough for every layer to fail closed, support deterministic testing, and undergo controlled dependency upgrades.

[Buy Me a Coffee](https://buymeacoffee.com/ducmng12g) | [Support Me on Ko-fi](https://ko-fi.com/I2I81AEJG8)
