refactor(shopinbit): parse ticket messages with the html package
What changed, and why it matters
This commit replaces a custom-built HTML parser in Stack Wallet's ShopinBit ticket-message feature with the well-known 'html' package. The old code parsed HTML by hand using string scanning and regular expressions, which is a common source of security bugs (for example, tricking the parser into treating malicious code as harmless text or vice versa). The new code uses a dedicated HTML parser that is more robust against malformed or adversarial input. The change is a defensive refactor; there is no direct evidence in the commit that an actual attack was found or exploited.
Treat as a positive hardening change. Review that package:html ^0.15.6 is pinned/locked to a known-good version, verify its transitive dependencies, and confirm that the new parser's behavior for malformed HTML and entity decoding matches the app's security expectations. No urgent patch action is indicated by the commit alone.
Security signals we found
Replaces custom HTML tokenizer/parser with a maintained library parser
Removes hand-rolled HTML entity decoding and regex attribute extraction
Reduces attack surface for HTML injection / mutation bypasses in ticket message rendering
No explicit bug fix, CVE, or exploit evidence present in the commit materials
Evidence from the diff
The diff refactors _parseSegments() in lib/services/shopinbit/src/models/message.dart to use package:html’s parseFragment() and DOM traversal instead of a hand-rolled linear scanner, regex-based attribute extraction, and a custom HTML entity decoder. The old implementation had to carefully handle quoted attribute values, tag boundaries, closing-tag matching, and entity decoding itself; such parsers are historically prone to mutation mismatches, bypasses, and regex-based denial-of-service. The new implementation flattens unknown markup to text, extracts src/alt/href from parsed DOM nodes, and lets the library handle entity decoding and malformed input. pubspec.template.yaml adds html: ^0.15.6 as a direct main dependency and pubspec.lock reflects that change.
Changed components
lib/services/shopinbit/src/models/message.dartShopinBit ticket message renderingpubspec dependency graph (html package promoted to direct main dependency)Inspect captured patch +33 / −168
diff --git a/lib/services/shopinbit/src/models/message.dart b/lib/services/shopinbit/src/models/message.dart
index 80e4752..58e45cd 100644
--- a/lib/services/shopinbit/src/models/message.dart
+++ b/lib/services/shopinbit/src/models/message.dart
@@ -1,6 +1,9 @@
import 'dart:convert';
import 'dart:typed_data';
+import 'package:html/dom.dart' as dom;
+import 'package:html/parser.dart' show parseFragment;
+
class TicketMessage {
final DateTime timestamp;
final bool fromAgent;
@@ -71,53 +74,35 @@ class MessageFileLinkSegment extends MessageContentSegment {
/// Parse a ticket message's HTML [content] into ordered renderable segments.
///
-/// A single linear scan rather than regexes: it keeps document order (text,
-/// inline images, proxy images and file links interleaved as they appear),
-/// tolerates `>` inside quoted attribute values, accepts either quote style,
-/// and runs in O(content length) with no catastrophic backtracking. Attachment
-/// `<img>`/`<a>` become media segments and their markup never leaks into text.
+/// Walks the parsed DOM in document order so text, inline images, proxy images
+/// and file links render where they appear. Attachment `<img>`/`<a>` become
+/// media segments and their markup is never shown as text; other markup is
+/// flattened to its text. The parser handles malformed/adversarial HTML and
+/// entity decoding, so there's no hand-rolled tokeniser to keep correct.
List<MessageContentSegment> _parseSegments(String content) {
final segments = <MessageContentSegment>[];
final text = StringBuffer();
void flushText() {
- final decoded = (unescapeHtml(text.toString()) ?? '').trim();
- if (decoded.isNotEmpty) segments.add(MessageTextSegment(decoded));
+ final trimmed = text.toString().trim();
+ if (trimmed.isNotEmpty) segments.add(MessageTextSegment(trimmed));
text.clear();
}
- final n = content.length;
- var i = 0;
- while (i < n) {
- final lt = content.indexOf('<', i);
- if (lt < 0) {
- text.write(content.substring(i));
- break;
- }
- if (lt > i) text.write(content.substring(i, lt));
-
- // HTML comment: skip past the closing `-->` (drop its contents entirely).
- if (content.startsWith('<!--', lt)) {
- final end = content.indexOf('-->', lt + 4);
- i = end < 0 ? n : end + 3;
- continue;
- }
-
- final gt = _tagEnd(content, lt);
- if (gt < 0) {
- // No closing `>`; the remainder can't be a tag, render it as text.
- text.write(content.substring(lt));
- break;
+ void visit(dom.Node node) {
+ if (node is dom.Text) {
+ text.write(node.data);
+ return;
}
- final tag = content.substring(lt, gt + 1);
- i = gt + 1;
+ if (node is! dom.Element) return;
- switch (_tagName(tag)) {
+ switch (node.localName) {
case 'br':
text.write('\n');
+ return;
case 'img':
- final src = unescapeHtml(_attr(tag, 'src'));
- if (src == null) break;
+ final src = node.attributes['src'];
+ if (src == null) return;
final bytes = _decodeInlineImage(src);
if (bytes != null) {
flushText();
@@ -129,96 +114,40 @@ List<MessageContentSegment> _parseSegments(String content) {
segments.add(
MessageProxyImageSegment(
proxyPath: proxyPath,
- filename: _emptyOrNull(unescapeHtml(_attr(tag, 'alt'))),
+ filename: _emptyOrNull(node.attributes['alt']),
),
);
}
}
+ return;
case 'a':
- final href = unescapeHtml(_attr(tag, 'href'));
+ final href = node.attributes['href'];
if (href != null && _isAttachmentProxy(href)) {
- // Consume through the matching </a>; its inner text is the link label
- // and must not also be emitted as body text.
- final close = _findClose(content, i, 'a');
- final inner = content.substring(i, close?.start ?? n);
- i = close?.end ?? n;
final proxyPath = _proxyPathOf(href);
if (proxyPath != null) {
flushText();
segments.add(
MessageFileLinkSegment(
proxyPath: proxyPath,
- filename: _emptyOrNull(_stripHtml(inner)),
+ filename: _emptyOrNull(node.text),
),
);
}
+ // The link text is its label, not body text; don't recurse.
+ return;
}
- // Any other tag (div, span, closing tags, ...) contributes no markup;
- // surrounding text flows through the buffer.
}
- }
- flushText();
- return segments;
-}
-/// Index of the `>` that closes the tag starting at [lt], skipping any `>` that
-/// sits inside a quoted attribute value. Returns -1 if the tag is unterminated.
-int _tagEnd(String s, int lt) {
- var i = lt + 1;
- String? quote;
- while (i < s.length) {
- final c = s[i];
- if (quote != null) {
- if (c == quote) quote = null;
- } else if (c == '"' || c == "'") {
- quote = c;
- } else if (c == '>') {
- return i;
+ for (final child in node.nodes) {
+ visit(child);
}
- i++;
}
- return -1;
-}
-/// The lowercased tag name from a raw tag string like `<img ...>` or `</a>`.
-String _tagName(String tag) {
- var i = 1; // skip '<'
- if (i < tag.length && tag[i] == '/') i++; // closing tag
- final start = i;
- while (i < tag.length) {
- final c = tag[i];
- if (c == ' ' ||
- c == '\t' ||
- c == '\n' ||
- c == '\r' ||
- c == '>' ||
- c == '/') {
- break;
- }
- i++;
- }
- return tag.substring(start, i).toLowerCase();
-}
-
-/// Find the closing `</name>` at or after [from], validating that `</name` is
-/// followed only by optional whitespace then `>` (so `</article>` doesn't match
-/// `</a>`). Returns the `<` index and the index just past `>`.
-({int start, int end})? _findClose(String s, int from, String name) {
- final lower = s.toLowerCase();
- final needle = '</$name';
- var idx = lower.indexOf(needle, from);
- while (idx >= 0) {
- var j = idx + needle.length;
- while (j < s.length &&
- (s[j] == ' ' || s[j] == '\t' || s[j] == '\n' || s[j] == '\r')) {
- j++;
- }
- if (j < s.length && s[j] == '>') {
- return (start: idx, end: j + 1);
- }
- idx = lower.indexOf(needle, idx + needle.length);
+ for (final node in parseFragment(content).nodes) {
+ visit(node);
}
- return null;
+ flushText();
+ return segments;
}
final _whitespaceRe = RegExp(r'\s');
@@ -267,16 +196,6 @@ Uint8List? _decodeInlineImage(String src) {
return bytes;
}
-String? _attr(String tag, String name) {
- final re = RegExp(
- '\\b$name\\s*=\\s*(?:"([^"]*)"|\'([^\']*)\')',
- caseSensitive: false,
- );
- final m = re.firstMatch(tag);
- if (m == null) return null;
- return m.group(1) ?? m.group(2);
-}
-
bool _isAttachmentProxy(String url) => url.contains('/attachment-proxy/');
String? _proxyPathOf(String url) {
@@ -314,58 +233,3 @@ String? _emptyOrNull(String? s) {
final t = s.trim();
return t.isEmpty ? null : t;
}
-
-String _stripHtml(String html) {
- final noTags = html.replaceAll(RegExp(r'<[^>]*>'), ' ');
- return unescapeHtml(noTags)!.replaceAll(RegExp(r'\s+'), ' ').trim();
-}
-
-final _entityRe = RegExp(r'&(#[xX]?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);');
-
-const _namedEntities = <String, String>{
- 'amp': '&',
- 'lt': '<',
- 'gt': '>',
- 'quot': '"',
- 'apos': "'",
- 'nbsp': ' ',
- 'mdash': '—',
- 'ndash': '–',
- 'hellip': '…',
- 'copy': '©',
- 'reg': '®',
- 'trade': '™',
- 'euro': '€',
- 'pound': '£',
- 'lsquo': '‘',
- 'rsquo': '’',
- 'ldquo': '“',
- 'rdquo': '”',
-};
-
-/// Decode the HTML entities the ticket API emits, in a single pass so a decoded
-/// `&` can't be re-read as the start of another entity (e.g. `&lt;` decodes
-/// to the literal `<`, not `<`). Covers the named entities plus numeric
-/// (`&#NN;`) and hex (`&#xNN;`) references; unknown entities are left as-is.
-/// Returns null for null input so it can be threaded through nullable attribute
-/// lookups.
-String? unescapeHtml(String? s) {
- if (s == null) return null;
- return s.replaceAllMapped(_entityRe, (m) {
- final body = m.group(1)!;
- if (body.startsWith('#')) {
- final isHex = body.length > 1 && (body[1] == 'x' || body[1] == 'X');
- final code = int.tryParse(
- isHex ? body.substring(2) : body.substring(1),
- radix: isHex ? 16 : 10,
- );
- if (code == null || code < 0 || code > 0x10FFFF) return m.group(0)!;
- try {
- return String.fromCharCode(code);
- } catch (_) {
- return m.group(0)!;
- }
- }
- return _namedEntities[body] ?? m.group(0)!;
- });
-}
diff --git a/pubspec.lock b/pubspec.lock
index 091d58a..2c7abc1 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -1321,7 +1321,7 @@ packages:
source: hosted
version: "1.0.2"
html:
- dependency: transitive
+ dependency: "direct main"
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml
index 6764428..4264b4c 100644
--- a/scripts/app_config/templates/pubspec.template.yaml
+++ b/scripts/app_config/templates/pubspec.template.yaml
@@ -169,6 +169,7 @@ dependencies:
image: ^4.3.0
wakelock_plus: ^1.2.8
intl: ^0.19.0
+ html: ^0.15.6
devicelocale:
git:
url: https://github.com/cypherstack/flutter-devicelocale
Why this scored 37/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.