From 9ad08f726c71e7780568dc8ed2011773498a46f7 Mon Sep 17 00:00:00 2001 From: Igor Furgala Date: Wed, 28 Oct 2026 19:45:58 +0100 Subject: [PATCH 1/6] fix(ios): decode named HTML entities like   (#818) --- .../flows/named_html_entities.yaml | 23 +++ ios/extensions/StringExtension.mm | 138 ++++++++++++++---- 2 files changed, 129 insertions(+), 32 deletions(-) create mode 100644 .maestro/enrichedText/flows/named_html_entities.yaml diff --git a/.maestro/enrichedText/flows/named_html_entities.yaml b/.maestro/enrichedText/flows/named_html_entities.yaml new file mode 100644 index 000000000..9d5ce4ae6 --- /dev/null +++ b/.maestro/enrichedText/flows/named_html_entities.yaml @@ -0,0 +1,23 @@ +appId: swmansion.enriched.example +--- +# Issue #818 - named character references ( , ", ', ...) are +# decoded instead of being rendered literally. +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +- tapOn: + id: 'toggle-enriched-text-screen-button' + +- runFlow: + file: '../subflows/set_enriched_text_value.yaml' + env: + VALUE: "

Hello world "q" 'a' —

" + +# '.' matches the decoded U+00A0 between "Hello" and "world" +- assertVisible: + text: "Hello.world \"q\" 'a' —" + +- assertNotVisible: + text: '.*&(nbsp|quot|apos|mdash);.*' diff --git a/ios/extensions/StringExtension.mm b/ios/extensions/StringExtension.mm index 6f0a9d5af..e0b71b558 100644 --- a/ios/extensions/StringExtension.mm +++ b/ios/extensions/StringExtension.mm @@ -11,45 +11,119 @@ + (NSString *)fromCppString:(std::string)string { } + (NSString *)stringByEscapingHtml:(NSString *)html { - NSMutableString *escaped = [html mutableCopy]; - NSDictionary *escapeMap = @{ - @"&" : @"&", - @"<" : @"<", - @">" : @">", - }; - - for (NSString *key in escapeMap) { - [escaped replaceOccurrencesOfString:key - withString:escapeMap[key] - options:NSLiteralSearch - range:NSMakeRange(0, escaped.length)]; + // Escape character by character so that the output of one replacement is + // never escaped again (e.g. U+00A0 -> " " must not become + // "&nbsp;"). + NSMutableString *escaped = + [[NSMutableString alloc] initWithCapacity:html.length]; + for (NSUInteger i = 0; i < html.length; i++) { + unichar c = [html characterAtIndex:i]; + switch (c) { + case '&': + [escaped appendString:@"&"]; + break; + case '<': + [escaped appendString:@"<"]; + break; + case '>': + [escaped appendString:@">"]; + break; + case 0x00A0: + // Non-breaking space is emitted as an entity so it stays visible in the + // HTML and survives whitespace trimming. + [escaped appendString:@" "]; + break; + default: + [escaped appendFormat:@"%C", c]; + break; + } } return escaped; } -+ (NSDictionary *)getEscapedCharactersInfoFrom:(NSString *)text { - NSDictionary *unescapeMap = @{ - @"&" : @"&", - @"<" : @"<", - @">" : @">", - }; +// Named character references decoded by the parser. This is not the full +// HTML5 table (2000+ entries); it covers the XML predefined entities and the +// common HTML ones that editors and CMSs emit in practice. Unknown names are +// left as literal text. +static NSDictionary *namedHtmlEntities(void) { + static NSDictionary *entities; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + entities = @{ + @"amp" : @"&", + @"lt" : @"<", + @"gt" : @">", + @"quot" : @"\"", + @"apos" : @"'", + @"nbsp" : @"\u00A0", + @"ensp" : @"\u2002", + @"emsp" : @"\u2003", + @"thinsp" : @"\u2009", + @"zwnj" : @"\u200C", + @"zwj" : @"\u200D", + @"shy" : @"\u00AD", + @"ndash" : @"\u2013", + @"mdash" : @"\u2014", + @"lsquo" : @"\u2018", + @"rsquo" : @"\u2019", + @"sbquo" : @"\u201A", + @"ldquo" : @"\u201C", + @"rdquo" : @"\u201D", + @"bdquo" : @"\u201E", + @"laquo" : @"\u00AB", + @"raquo" : @"\u00BB", + @"hellip" : @"\u2026", + @"bull" : @"\u2022", + @"middot" : @"\u00B7", + @"iexcl" : @"\u00A1", + @"iquest" : @"\u00BF", + @"copy" : @"\u00A9", + @"reg" : @"\u00AE", + @"trade" : @"\u2122", + @"sect" : @"\u00A7", + @"para" : @"\u00B6", + @"deg" : @"\u00B0", + @"plusmn" : @"\u00B1", + @"times" : @"\u00D7", + @"divide" : @"\u00F7", + @"euro" : @"\u20AC", + @"cent" : @"\u00A2", + @"pound" : @"\u00A3", + @"yen" : @"\u00A5", + }; + }); + return entities; +} ++ (NSDictionary *)getEscapedCharactersInfoFrom:(NSString *)text { NSMutableDictionary *results = [[NSMutableDictionary alloc] init]; - for (NSString *key in unescapeMap) { - NSRange searchRange = NSMakeRange(0, text.length); - NSRange foundRange; - - while (searchRange.location < text.length) { - foundRange = [text rangeOfString:key options:0 range:searchRange]; - if (foundRange.location == NSNotFound) { - break; - } - results[@(foundRange.location)] = @[ key, unescapeMap[key] ]; - searchRange.location = foundRange.location + foundRange.length; - searchRange.length = text.length - searchRange.location; - } - } + // Named character references: &name; + NSDictionary *entities = namedHtmlEntities(); + NSRegularExpression *namedEntityRegex = + [NSRegularExpression regularExpressionWithPattern:@"&([a-zA-Z0-9]+);" + options:0 + error:nil]; + + [namedEntityRegex + enumerateMatchesInString:text + options:0 + range:NSMakeRange(0, text.length) + usingBlock:^(NSTextCheckingResult *match, + NSMatchingFlags flags, BOOL *stop) { + if (match == nil) { + return; + } + NSString *name = + [text substringWithRange:[match rangeAtIndex:1]]; + NSString *decoded = entities[name]; + if (decoded == nil) { + return; + } + NSRange fullRange = [match range]; + results[@(fullRange.location)] = + @[ [text substringWithRange:fullRange], decoded ]; + }]; // Numeric character references: &#NNNN; (decimal) and &#xHHHH; (hex) NSRegularExpression *numericEntityRegex = [NSRegularExpression From c83d11670bc4137e0708f59f406d925347bf49a3 Mon Sep 17 00:00:00 2001 From: szydlovsky <9szydlowski9@gmail.com> Date: Mon, 2 Nov 2026 17:37:36 +0100 Subject: [PATCH 2/6] fix: improve encoding/decoding for html attributes too --- ios/extensions/StringExtension.h | 2 ++ ios/extensions/StringExtension.mm | 45 +++++++++++++++++++++++-- ios/htmlParser/HtmlParser.mm | 55 +++++++++++++++---------------- 3 files changed, 71 insertions(+), 31 deletions(-) diff --git a/ios/extensions/StringExtension.h b/ios/extensions/StringExtension.h index 365ae8ab1..366a95cb1 100644 --- a/ios/extensions/StringExtension.h +++ b/ios/extensions/StringExtension.h @@ -7,6 +7,8 @@ + (NSString *)fromCppString:(std::string)string; + (NSString *)stringByEscapingHtml:(NSString *)html; + (NSDictionary *)getEscapedCharactersInfoFrom:(NSString *)text; ++ (NSString *)stringByEscapingHtmlAttribute:(NSString *)value; ++ (NSString *)stringByUnescapingHtmlAttribute:(NSString *)value; @end @interface NSMutableString (StringExtension) diff --git a/ios/extensions/StringExtension.mm b/ios/extensions/StringExtension.mm index e0b71b558..ecbccaaa9 100644 --- a/ios/extensions/StringExtension.mm +++ b/ios/extensions/StringExtension.mm @@ -12,8 +12,7 @@ + (NSString *)fromCppString:(std::string)string { + (NSString *)stringByEscapingHtml:(NSString *)html { // Escape character by character so that the output of one replacement is - // never escaped again (e.g. U+00A0 -> " " must not become - // "&nbsp;"). + // never escaped again. NSMutableString *escaped = [[NSMutableString alloc] initWithCapacity:html.length]; for (NSUInteger i = 0; i < html.length; i++) { @@ -203,6 +202,48 @@ + (NSDictionary *)getEscapedCharactersInfoFrom:(NSString *)text { return results; } +// Escapes the characters that would otherwise let a value break out of a +// HTML attribute (e.g. href="%@", src="%@", style="%@"). ++ (NSString *)stringByEscapingHtmlAttribute:(NSString *)value { + NSMutableString *escaped = + [[NSMutableString alloc] initWithCapacity:value.length]; + for (NSUInteger i = 0; i < value.length; i++) { + unichar c = [value characterAtIndex:i]; + switch (c) { + case '&': + [escaped appendString:@"&"]; + break; + case '<': + [escaped appendString:@"<"]; + break; + case '>': + [escaped appendString:@">"]; + break; + case '"': + [escaped appendString:@"""]; + break; + default: + [escaped appendFormat:@"%C", c]; + break; + } + } + return escaped; +} + +// Reverses stringByEscapingHtmlAttribute:, and additionally decodes ' +// since it's a value some external HTML sources may still use. ++ (NSString *)stringByUnescapingHtmlAttribute:(NSString *)value { + NSString *result = value; + for (NSArray *entity in @[ + @[ @""", @"\"" ], @[ @"'", @"'" ], @[ @"'", @"'" ], + @[ @"<", @"<" ], @[ @">", @">" ], @[ @"&", @"&" ] + ]) { + result = [result stringByReplacingOccurrencesOfString:entity[0] + withString:entity[1]]; + } + return result; +} + @end @implementation NSMutableString (StringExtension) diff --git a/ios/htmlParser/HtmlParser.mm b/ios/htmlParser/HtmlParser.mm index 123f80820..45853dee1 100644 --- a/ios/htmlParser/HtmlParser.mm +++ b/ios/htmlParser/HtmlParser.mm @@ -697,7 +697,7 @@ + (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)fixedHtml [params substringWithRange:NSMakeRange(srcRange.location + 5, srcRange.length - 6)]; ImageData *imageData = [[ImageData alloc] init]; - imageData.uri = uri; + imageData.uri = [NSString stringByUnescapingHtmlAttribute:uri]; NSRegularExpression *widthRegex = [NSRegularExpression regularExpressionWithPattern:@"width=\"([0-9.]+)\"" @@ -755,9 +755,10 @@ + (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)fixedHtml NSRange hrefRange = match.range; [styleArr addObject:@([LinkStyle getType])]; // cut only the url from the href="..." string - NSString *url = + NSString *rawUrl = [params substringWithRange:NSMakeRange(hrefRange.location + 6, hrefRange.length - 7)]; + NSString *url = [NSString stringByUnescapingHtmlAttribute:rawUrl]; // tagRange location includes one extra offset per preceding image // placeholder, which don't exist in plainText. Subtract them to map @@ -794,9 +795,10 @@ + (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)fixedHtml if (result.numberOfRanges == 4) { NSString *key = [params substringWithRange:[result rangeAtIndex:1]]; - NSString *value = [params + NSString *rawValue = [params substringWithRange:[result rangeAtIndex:3]]; - paramsDict[key] = value; + paramsDict[key] = [NSString + stringByUnescapingHtmlAttribute:rawValue]; } }]; @@ -1391,9 +1393,11 @@ + (NSString *)tagContentForStyle:(NSNumber *)style if (imageStyle != nullptr) { ImageData *data = [imageStyle getImageDataAt:location]; if (data != nullptr && data.uri != nullptr) { + NSString *escapedUri = + [NSString stringByEscapingHtmlAttribute:data.uri]; return [NSString stringWithFormat:@"img src=\"%@\" width=\"%d\" height=\"%d\"", - data.uri, (int)floor(data.width), + escapedUri, (int)floor(data.width), (int)floor(data.height)]; } } @@ -1414,7 +1418,9 @@ + (NSString *)tagContentForStyle:(NSNumber *)style if (linkStyle != nullptr) { LinkData *data = [linkStyle getLinkDataAt:location]; if (data != nullptr && data.url != nullptr) { - return [NSString stringWithFormat:@"a href=\"%@\"", data.url]; + NSString *escapedUrl = + [NSString stringByEscapingHtmlAttribute:data.url]; + return [NSString stringWithFormat:@"a href=\"%@\"", escapedUrl]; } } return @"a"; @@ -1444,15 +1450,22 @@ + (NSString *)tagContentForStyle:(NSNumber *)style // format dict keys and values into string [json enumerateKeysAndObjectsUsingBlock:^( id _Nonnull key, id _Nonnull obj, BOOL *_Nonnull stop) { - [attrsStr - appendString:[NSString stringWithFormat:@" %@=\"%@\"", - (NSString *)key, - (NSString *)obj]]; + NSString *escapedKey = + [NSString stringByEscapingHtmlAttribute:(NSString *)key]; + NSString *escapedValue = + [NSString stringByEscapingHtmlAttribute:(NSString *)obj]; + [attrsStr appendString:[NSString stringWithFormat:@" %@=\"%@\"", + escapedKey, + escapedValue]]; }]; } + NSString *escapedText = + [NSString stringByEscapingHtmlAttribute:params.text]; + NSString *escapedIndicator = + [NSString stringByEscapingHtmlAttribute:params.indicator]; return [NSString stringWithFormat:@"mention text=\"%@\" indicator=\"%@\"%@", - params.text, params.indicator, attrsStr]; + escapedText, escapedIndicator, attrsStr]; } } return @"mention"; @@ -1529,17 +1542,7 @@ + (NSString *)tagContentForStyle:(NSNumber *)style } if (cssProps.length > 0) { NSString *escapedStyle = - [cssProps stringByReplacingOccurrencesOfString:@"&" - withString:@"&"]; - escapedStyle = - [escapedStyle stringByReplacingOccurrencesOfString:@"<" - withString:@"<"]; - escapedStyle = - [escapedStyle stringByReplacingOccurrencesOfString:@">" - withString:@">"]; - escapedStyle = - [escapedStyle stringByReplacingOccurrencesOfString:@"\"" - withString:@"""]; + [NSString stringByEscapingHtmlAttribute:cssProps]; return [NSString stringWithFormat:@"span style=\"%@\"", escapedStyle]; } @@ -1637,13 +1640,7 @@ + (CustomStyleData *_Nullable)parseCustomStyleDataFromSpanParams: NSString *css = [params substringWithRange:[attrMatch rangeAtIndex:1]]; // decode entities escaped by the serializers - for (NSArray *entity in @[ - @[ @""", @"\"" ], @[ @"'", @"'" ], @[ @"<", @"<" ], - @[ @">", @">" ], @[ @"&", @"&" ] - ]) { - css = [css stringByReplacingOccurrencesOfString:entity[0] - withString:entity[1]]; - } + css = [NSString stringByUnescapingHtmlAttribute:css]; CustomStyleData *data = [[CustomStyleData alloc] init]; NSTextCheckingResult *fgMatch = From f24756c6424d77bb2a13a08613e3cad6dcca4a36 Mon Sep 17 00:00:00 2001 From: szydlovsky <9szydlowski9@gmail.com> Date: Mon, 2 Nov 2026 20:18:29 +0100 Subject: [PATCH 3/6] feat: add tests --- .../flows/link_href_attribute_escaping.yaml | 73 +++++++++++++++++++ .../image_attribute_entity_decoding.yaml | 26 +++++++ .../flows/link_attribute_entity_decoding.yaml | 26 +++++++ .../mention_attribute_entity_decoding.yaml | 27 +++++++ apps/example/src/screens/TestScreen.tsx | 2 + 5 files changed, 154 insertions(+) create mode 100644 .maestro/enrichedInput/flows/link_href_attribute_escaping.yaml create mode 100644 .maestro/enrichedText/flows/image_attribute_entity_decoding.yaml create mode 100644 .maestro/enrichedText/flows/link_attribute_entity_decoding.yaml create mode 100644 .maestro/enrichedText/flows/mention_attribute_entity_decoding.yaml diff --git a/.maestro/enrichedInput/flows/link_href_attribute_escaping.yaml b/.maestro/enrichedInput/flows/link_href_attribute_escaping.yaml new file mode 100644 index 000000000..56d5712cb --- /dev/null +++ b/.maestro/enrichedInput/flows/link_href_attribute_escaping.yaml @@ -0,0 +1,73 @@ +appId: swmansion.enriched.example +--- +# Regression test for href="..." attribute-injection: a URL containing " (or +# &) must be HTML-attribute-escaped on serialize, and the escaped output must +# decode back to the original, un-escaped URL when re-parsed. +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +- tapOn: + id: 'editor-input' + +- inputText: 'Hello ' + +- runFlow: + file: '../subflows/insert_link.yaml' + env: + LINK_TEXT: 'test' + LINK_URL: 'https://example.com/a&b"c' + +- extendedWaitUntil: + notVisible: + id: 'link-modal-submit-button' + timeout: 10000 + +- tapOn: + id: 'toggle-html-button' + +# the raw " and & must not reach the output unescaped (that's the +# attribute-injection bug: it would break out of href="...") +- assertVisible: + id: 'html-output' + text: '.*href="https://example.com/a&b"c".*' + +- assertNotVisible: + id: 'html-output' + text: '.*href="https://example.com/a&b"c".*' + +# Restart the app before the round-trip check below. On iOS, presenting a +# second Modal right after dismissing one (LinkModal -> ValueModal) can leave +# the accessibility snapshot stuck empty indefinitely - the screen still +# renders correctly, but Maestro can't see any element in it. A full relaunch +# resets the native view/accessibility state; losing the editor content here +# is fine since the next step overwrites it anyway via "Set Value". +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +# Feed the escaped HTML back in. The round trip is verified via the HTML +# output box rather than by reopening the link modal to read LinkData.url: +# reopening it is a second Modal in this same session and hits the exact +# accessibility-snapshot issue above. Checking the re-serialized HTML instead +# proves the same thing - decode must turn "&b"c" back into the raw +# "&b"c" that re-encode then escapes right back to "&b"c" - without +# ever presenting a second Modal. +- runFlow: + file: '../subflows/set_editor_value.yaml' + env: + VALUE: '

test

' + +- tapOn: + id: 'toggle-html-button' + +- assertVisible: + id: 'html-output' + text: '.*href="https://example.com/a&b"c".*' + +# if decode left the entities un-decoded, re-encoding would double-escape them +- assertNotVisible: + id: 'html-output' + text: '.*href="https://example.com/a&amp;.*' diff --git a/.maestro/enrichedText/flows/image_attribute_entity_decoding.yaml b/.maestro/enrichedText/flows/image_attribute_entity_decoding.yaml new file mode 100644 index 000000000..0ae4f1fec --- /dev/null +++ b/.maestro/enrichedText/flows/image_attribute_entity_decoding.yaml @@ -0,0 +1,26 @@ +appId: swmansion.enriched.example +--- +# Entities inside src="..." must decode too - regression test for the src +# attribute-escaping fix. +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +- tapOn: + id: 'toggle-enriched-text-screen-button' + +- runFlow: + file: '../subflows/set_enriched_text_value.yaml' + env: + VALUE: '' + +- tapOn: + id: 'enriched-text' + point: '5%,50%' + +- assertVisible: + text: 'You pressed the image:.*img&x.png.*' + +- assertNotVisible: + text: '.*&.*' diff --git a/.maestro/enrichedText/flows/link_attribute_entity_decoding.yaml b/.maestro/enrichedText/flows/link_attribute_entity_decoding.yaml new file mode 100644 index 000000000..e7eb0c1fe --- /dev/null +++ b/.maestro/enrichedText/flows/link_attribute_entity_decoding.yaml @@ -0,0 +1,26 @@ +appId: swmansion.enriched.example +--- +# Entities inside href="..." must decode too, not just inside text content - +# regression test for the href attribute-escaping fix. +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +- tapOn: + id: 'toggle-enriched-text-screen-button' + +- runFlow: + file: '../subflows/set_enriched_text_value.yaml' + env: + VALUE: '

link

' + +- tapOn: + id: 'enriched-text' + point: '5%,50%' + +- assertVisible: + text: 'You pressed the link: https://example.com/a&b"c' + +- assertNotVisible: + text: '.*&(amp|quot);.*' diff --git a/.maestro/enrichedText/flows/mention_attribute_entity_decoding.yaml b/.maestro/enrichedText/flows/mention_attribute_entity_decoding.yaml new file mode 100644 index 000000000..f7e80ad12 --- /dev/null +++ b/.maestro/enrichedText/flows/mention_attribute_entity_decoding.yaml @@ -0,0 +1,27 @@ +appId: swmansion.enriched.example +--- +# Entities inside mention attributes (a JSON blob spliced into key="value" +# pairs) must decode too - regression test for the mention attribute-escaping +# fix. +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +- tapOn: + id: 'toggle-enriched-text-screen-button' + +- runFlow: + file: '../subflows/set_enriched_text_value.yaml' + env: + VALUE: '

@John Doe

' + +- tapOn: + id: 'enriched-text' + point: '5%,50%' + +- assertVisible: + text: 'You pressed the mention:.*"id":"1&2".*' + +- assertNotVisible: + text: '.*&.*' diff --git a/apps/example/src/screens/TestScreen.tsx b/apps/example/src/screens/TestScreen.tsx index 1e64d5225..85266b36b 100644 --- a/apps/example/src/screens/TestScreen.tsx +++ b/apps/example/src/screens/TestScreen.tsx @@ -7,6 +7,7 @@ import { LinkModal } from '../components/LinkModal'; import { MentionPopup } from '../components/MentionPopup'; import { ImageModal } from '../components/ImageModal'; import { ValueModal } from '../components/ValueModal'; +import { HtmlSection } from '../components/HtmlSection'; import { useEditorState } from '../hooks/useEditorState'; import { useKeyboardHeight } from '../hooks/useKeyboardHeight'; import { @@ -111,6 +112,7 @@ export function TestScreen({ layout="grid" /> +