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&.*'
diff --git a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_1.png b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_1.png
index 650394951..84c65f7b9 100644
Binary files a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_1.png and b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_1.png differ
diff --git a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_2.png b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_2.png
index a50059814..2d8ebc5c8 100644
Binary files a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_2.png and b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_2.png differ
diff --git a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_3.png b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_3.png
index dd2d16428..7ed8418be 100644
Binary files a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_3.png and b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_3.png differ
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/.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/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"
/>
+
" : @">",
- };
-
- 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.
+ 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 HHHH; (hex)
NSRegularExpression *numericEntityRegex = [NSRegularExpression
@@ -129,6 +202,59 @@ + (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;
+}
+
+// Attribute values may come from arbitrary input HTML (not just our own
+// serializer's output), so this decodes the same full named/numeric entity set
+// as text content.
++ (NSString *)stringByUnescapingHtmlAttribute:(NSString *)value {
+ NSDictionary *entityInfo = [self getEscapedCharactersInfoFrom:value];
+ if (entityInfo.count == 0) {
+ return value;
+ }
+ NSMutableString *result = [value mutableCopy];
+ // Apply from the highest offset down so replacing one match never shifts
+ // the other matches.
+ NSArray *locations = [[entityInfo allKeys]
+ sortedArrayUsingDescriptors:@[ [NSSortDescriptor
+ sortDescriptorWithKey:@"integerValue"
+ ascending:NO] ]];
+ for (NSNumber *location in locations) {
+ NSArray *entity = entityInfo[location];
+ NSString *escaped = entity[0];
+ NSString *decoded = entity[1];
+ NSRange range = NSMakeRange(location.unsignedIntegerValue, escaped.length);
+ [result replaceCharactersInRange:range withString:decoded];
+ }
+ 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 =