-
Notifications
You must be signed in to change notification settings - Fork 3.6k
HTML API: Auto-escape JavaScript and JSON script tag contents when necessary #10635
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
sirreal
wants to merge
73
commits into
WordPress:trunk
from
sirreal:html-api/auto-escape-javascript-json
Closed
Changes from 60 commits
Commits
Show all changes
73 commits
Select commit
Hold shift + click to select a range
4a8ca98
Auto-escape JavaScript and JSON script tags when necessary
sirreal 4a28ef2
Update ticket number
sirreal 0bef687
Fix those lints
sirreal cdba027
No trailing function commas
sirreal ba54ae4
Remove JSON_THROW_ON_ERROR constant
sirreal a697e9e
fixup! Remove JSON_THROW_ON_ERROR constant
sirreal 2b3d0d0
Merge branch 'trunk' into html-api/auto-escape-javascript-json
sirreal 8246439
Remove JSON +json subtype handling
sirreal 3f7f227
Merge branch 'trunk' into html-api/auto-escape-javascript-json
sirreal 1362451
Add JS and JSON script tag tests
Copilot 8d30680
Fix typo in comment
sirreal 3b5ef4e
Improve comment
sirreal f8cfdf9
Clean up and fix tests
sirreal 501d201
Clean up and improve type/language logic
sirreal bcc02ae
Fix svg SCRIPT tag tests
sirreal ea03441
Add todo on MIME type parsing
sirreal c7d1827
Update since tags 🤞
sirreal a134e82
Make is_{javascript,json}_script_tag methods private
sirreal d4bd4b3
Add language whitespace test
sirreal edae8d5
How'd that extra space get there, remove it!
sirreal 02ca3c0
Name search parts
sirreal d058a78
Clean up escaping tests
sirreal dfb63af
Add ignore and todo tags to new private is_*_script_tag functions
sirreal 11f51c9
Improve example comment
sirreal a3e0e27
Update regex comment with named groups
sirreal 288b952
Add details to "other" failure to escape comment
sirreal a7495dd
Improve consistency of comment quoting and differentiate types of dou…
sirreal d8c320c
Describe JavaScript escaping strategy in detail
sirreal 369eefc
Use the updated_html value in round-trip test
sirreal 55d4e47
Merge branch 'trunk' into html-api/auto-escape-javascript-json
sirreal 2ef0bf0
Fix demonstration comment
sirreal cb6b990
Extract and document escaping functions
sirreal e399bf6
Tweak workaround documentation
sirreal 83c1fab
Add ASCII chart about HTML script tags with original source
sirreal 1872681
Improve linking between escapes
sirreal 83ff62f
Fix comments, typos, lints
sirreal 5979782
fixup! Fix comments, typos, lints
sirreal d469ae4
fixup! fixup! Fix comments, typos, lints
sirreal 402ae9f
Fix \c -> \r (carriage return) typo
sirreal 6b2c9ba
Add note about not parsing MIME types for JS script tags
sirreal eef0ccb
Add todo comment to is_json_script_tag
sirreal 71d2686
Re-order tag name termination chars to match elsewhere
sirreal d4693a2
Fix typo
sirreal eb4e091
Merge branch 'trunk' into html-api/auto-escape-javascript-json
sirreal 4c3b0b2
Update comments on tag prefixes matching search pattern
sirreal bfa60cf
Use content-type identification and escape without PCRE
dmsnell 3252160
fixup! Use content-type identification and escape without PCRE
dmsnell 0b9b2bd
fixup! Use content-type identification and escape without PCRE
dmsnell b2533e1
fixup! Use content-type identification and escape without PCRE
dmsnell ddeb301
fixup! Use content-type identification and escape without PCRE
dmsnell b36fc32
fixup! Use content-type identification and escape without PCRE
dmsnell 1249af0
fixup! Use content-type identification and escape without PCRE
dmsnell 645cb45
Update escaping comment
sirreal f23b35b
Fix off-by-one repeat processing after script
sirreal 31cefb7
Restructure escaping logic
sirreal 25279e3
Merge and remove duplicate script tag content type tests
sirreal 6660196
Merge branch 'trunk' into html-api/auto-escape-javascript-json
sirreal b436e71
Editing review of the escaping method docblock.
dmsnell 95259d2
Fixup: Missed a couple of box drawing connections.
dmsnell 91c019d
Fixup: Missed a curved corner
dmsnell 09e3aa2
Tweak language in JS escaping comments
sirreal 30d7747
Merge branch 'trunk' into html-api/auto-escape-javascript-json
sirreal c1c6067
Merge branch 'trunk' into html-api/auto-escape-javascript-json
sirreal 3f1ba32
Update dot script to clean up fonts, align labels, etc...
dmsnell f7715fd
Update note in `set_modifiable_text()` now that `</script` is allowed
dmsnell 1015ace
Inline escaping to avoid WPCS-mandated misalignment.
dmsnell 59ccdc1
Expand the explanation for why other strings are rejected
dmsnell ef34aa1
Explain that there are non-HTML SCRIPT tags.
dmsnell d018cdf
Wording changes to comment.
dmsnell c59028e
Make escape_javascript_script_contents private
sirreal ae14a44
Merge branch 'trunk' into html-api/auto-escape-javascript-json
sirreal 6a5bc56
Tweak some langauge in "other" escaping
sirreal df2f663
fixup! Tweak some langauge in "other" escaping
sirreal File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3811,37 +3811,35 @@ public function set_modifiable_text( string $plaintext_content ): bool { | |
|
|
||
| switch ( $this->get_tag() ) { | ||
| case 'SCRIPT': | ||
| /** | ||
| * This is over-protective, but ensures the update doesn't break | ||
| * the HTML structure of the SCRIPT element. | ||
| * | ||
| * More thorough analysis could track the HTML tokenizer states | ||
| * and to ensure that the SCRIPT element closes at the expected | ||
| * SCRIPT close tag as is done in {@see ::skip_script_data()}. | ||
| * | ||
| * A SCRIPT element could be closed prematurely by contents | ||
| * like `</script>`. A SCRIPT element could be prevented from | ||
| * closing by contents like `<!--<script>`. | ||
| * | ||
| * The following strings are essential for dangerous content, | ||
| * although they are insufficient on their own. This trade-off | ||
| * prevents dangerous scripts from being sent to the browser. | ||
| * It is also unlikely to produce HTML that may confuse more | ||
| * basic HTML tooling. | ||
| $script_content_type = $this->get_script_content_type(); | ||
|
|
||
| switch ( $script_content_type ) { | ||
| case 'javascript': | ||
| case 'json': | ||
| $escaped_content = self::escape_javascript_script_contents( $plaintext_content ); | ||
| $this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement( | ||
| $this->text_starts_at, | ||
| $this->text_length, | ||
| $escaped_content | ||
| ); | ||
| return true; | ||
| } | ||
|
|
||
| /* | ||
| * Unrecognized script content types cannot be escaped. | ||
| * Reject contents that are potentially dangerous. | ||
| */ | ||
| if ( | ||
| false !== stripos( $plaintext_content, '</script' ) || | ||
| false !== stripos( $plaintext_content, '<script' ) | ||
| false !== stripos( $plaintext_content, '<script' ) || | ||
| false !== stripos( $plaintext_content, '</script' ) | ||
| ) { | ||
| return false; | ||
| } | ||
|
|
||
| $this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement( | ||
| $this->text_starts_at, | ||
| $this->text_length, | ||
| $plaintext_content | ||
| ); | ||
|
|
||
| return true; | ||
|
|
||
| case 'STYLE': | ||
|
|
@@ -3891,6 +3889,341 @@ static function ( $tag_match ) { | |
| return false; | ||
| } | ||
|
|
||
| /** | ||
| * Returns the content type of the currently-matched SCRIPT tag, if matched and | ||
| * recognized, otherwise returns `null` to indicate an unrecognized content type. | ||
| * | ||
| * Note! This concept is related but distinct from the MIME type of the script. | ||
| * Parsing MUST match the specific algorithm in the HTML specification, which | ||
| * relies on exact string comparison in some cases. | ||
| * | ||
| * Only 'javascript' and 'json' content types are currently recognized. | ||
| * | ||
| * @see https://html.spec.whatwg.org/multipage/scripting.html#prepare-the-script-element | ||
| * | ||
| * @since 7.0.0 | ||
| * | ||
| * @return 'javascript'|'json'|null Type of script element content if matched and recognized. | ||
| */ | ||
| private function get_script_content_type(): ?string { | ||
| // SVG and MathML SCRIPT elements are not recognized. | ||
| if ( 'SCRIPT' !== $this->get_tag() || $this->get_namespace() !== 'html' ) { | ||
| return null; | ||
| } | ||
|
|
||
| /* | ||
| * > If any of the following are true: | ||
| * > - el has a type attribute whose value is the empty string; | ||
| * > - el has no type attribute but it has a language attribute and that attribute's | ||
| * > value is the empty string; or | ||
| * > - el has neither a type attribute nor a language attribute, | ||
| * > then let the script block's type string for this script element be "text/javascript". | ||
| */ | ||
| $type = $this->get_attribute( 'type' ); | ||
| $lang = $this->get_attribute( 'language' ); | ||
|
|
||
| if ( true === $type || '' === $type ) { | ||
| return 'javascript'; | ||
| } | ||
|
|
||
| if ( null === $type && ( null === $lang || true === $lang || '' === $lang ) ) { | ||
| return 'javascript'; | ||
| } | ||
|
|
||
| /* | ||
| * > Otherwise, if el has a type attribute, then let the script block's type string be | ||
| * > the value of that attribute with leading and trailing ASCII whitespace stripped. | ||
| * > Otherwise, el has a non-empty language attribute; let the script block's type string | ||
| * > be the concatenation of "text/" and the value of el's language attribute. | ||
| */ | ||
| $type_string = is_string( $type ) ? trim( $type, " \t\f\r\n" ) : "text/{$lang}"; | ||
|
|
||
| // All matches are ASCII case-insensitive; eagerly lower-case for comparison. | ||
| $type_string = strtolower( $type_string ); | ||
|
|
||
| /* | ||
| * > If the script block's type string is a JavaScript MIME type essence match, then | ||
| * > set el's type to "classic". | ||
| * | ||
| * > A string is a JavaScript MIME type essence match if it is an ASCII case-insensitive | ||
| * > match for one of the JavaScript MIME type essence strings. | ||
| * | ||
| * > A JavaScript MIME type is any MIME type whose essence is one of the following: | ||
| * > | ||
| * > - application/ecmascript | ||
| * > - application/javascript | ||
| * > - application/x-ecmascript | ||
| * > - application/x-javascript | ||
| * > - text/ecmascript | ||
| * > - text/javascript | ||
| * > - text/javascript1.0 | ||
| * > - text/javascript1.1 | ||
| * > - text/javascript1.2 | ||
| * > - text/javascript1.3 | ||
| * > - text/javascript1.4 | ||
| * > - text/javascript1.5 | ||
| * > - text/jscript | ||
| * > - text/livescript | ||
| * > - text/x-ecmascript | ||
| * > - text/x-javascript | ||
| * | ||
| * @see https://mimesniff.spec.whatwg.org/#javascript-mime-type-essence-match | ||
| * @see https://mimesniff.spec.whatwg.org/#javascript-mime-type | ||
| */ | ||
| switch ( $type_string ) { | ||
| case 'application/ecmascript': | ||
| case 'application/javascript': | ||
| case 'application/x-ecmascript': | ||
| case 'application/x-javascript': | ||
| case 'text/ecmascript': | ||
| case 'text/javascript': | ||
| case 'text/javascript1.0': | ||
| case 'text/javascript1.1': | ||
| case 'text/javascript1.2': | ||
| case 'text/javascript1.3': | ||
| case 'text/javascript1.4': | ||
| case 'text/javascript1.5': | ||
| case 'text/jscript': | ||
| case 'text/livescript': | ||
| case 'text/x-ecmascript': | ||
| case 'text/x-javascript': | ||
| return 'javascript'; | ||
|
|
||
| /* | ||
| * > Otherwise, if the script block's type string is an ASCII case-insensitive match for | ||
| * > the string "module", then set el's type to "module". | ||
| * | ||
| * A module is evaluated as JavaScript. | ||
| */ | ||
| case 'module': | ||
| return 'javascript'; | ||
|
|
||
| /* | ||
| * > Otherwise, if the script block's type string is an ASCII case-insensitive match for the string "importmap", then set el's type to "importmap". | ||
| * > Otherwise, if the script block's type string is an ASCII case-insensitive match for the string "speculationrules", then set el's type to "speculationrules". | ||
| * | ||
| * These conditions indicate JSON content. | ||
| */ | ||
| case 'importmap': | ||
| case 'speculationrules': | ||
| return 'json'; | ||
|
|
||
| /** @todo Rely on a full MIME parser for determining JSON content. */ | ||
| case 'application/json': | ||
|
sirreal marked this conversation as resolved.
|
||
| case 'text/json': | ||
| return 'json'; | ||
| } | ||
|
|
||
| /* | ||
| * > Otherwise, return. (No script is executed, and el's type is left as null.) | ||
| */ | ||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Escape JavaScript and JSON script tag contents. | ||
| * | ||
| * Ensure that the script contents cannot modify the HTML structure or break out | ||
| * of its containing SCRIPT element. JavaScript and JSON may both be escaped with | ||
| * the same rules, even though there are additional escaping measures available | ||
| * to JavaScript source code which aren’t applicable to serialized JSON data. | ||
| * | ||
| * A simple method safely escapes all content except for a few extremely rare and | ||
| * unlikely exceptions: prevent the appearance of `<script` and `</script` within | ||
| * the contents by replacing the first letter of the tag name with a Unicode escape. | ||
| * | ||
| * Example: | ||
| * | ||
| * $plaintext = '<script>document.write( "A </script> closes a script." );</script>'; | ||
| * $escaped = '<script>document.write( "A </\u0073cript> closes a script." );</script>'; | ||
| * | ||
| * This works because of how parsing changes after encountering an opening SCRIPT | ||
| * tag. The actual parsing comprises a complicated state machine, the result of | ||
| * legacy behaviors and diverse browser support. However, without these two strings | ||
| * in the script contents, the machine collapses to a single state. A JavaScript engine | ||
|
sirreal marked this conversation as resolved.
Outdated
|
||
| * or JSON decoder will then decode the Unicode escape (`\u0073`) back into its original | ||
| * plaintext value, but only after having been safely extracted from the HTML. | ||
| * | ||
| * While it may seem tempting to replace the `<` characters instead, doing so would | ||
| * only work within certain parts of JavaScript: identifiers, strings, etc… This | ||
| * would accidentally turn comparison operators into syntax errors! | ||
|
sirreal marked this conversation as resolved.
Outdated
|
||
| * | ||
| * ### Exceptions | ||
| * | ||
| * This _should_ work everywhere, but there are some extreme exceptions. | ||
| * | ||
| * - Comments. | ||
| * - Tagged templates, such as `String.raw()`, which provide access to “raw” strings. | ||
| * - The `source` property of a RegExp object. | ||
| * | ||
| * Each of these exceptions appear at the source code level, not at the semantic or | ||
| * evaluation level. Normal JavaScript will remain semantically equivalent after escaping, | ||
| * but any JavaScript which analyzes the raw source code will see potentially-different | ||
| * values. | ||
| * | ||
| * #### Comments | ||
| * | ||
| * Comments are never unescaped because they aren’t parsed by the JavaScript engine. | ||
| * When viewing the source in a browser’s developer tools, the comments will retain | ||
| * their escaped text. | ||
| * | ||
| * Example: | ||
| * | ||
| * // A comment: "</script>" | ||
| * …becomes… | ||
| * // A comment: "</\u0073cript>" | ||
| * | ||
| * #### Tagged templates. | ||
| * | ||
| * Tagged templates “enable the embedding of arbitrary string content, where escape | ||
| * sequences may follow a different syntax.” For example, they can aid representing | ||
| * a RegExp pattern or LaTex snippet within a JavaScript string, where the string | ||
| * escape characters might get noisy and distracting. | ||
| * | ||
| * Example: | ||
| * | ||
| * console.log( 'A \notin B' ); // Prints a newline because of the "\n". | ||
| * console.log( 'A \\notin B' ); // Prints "A \notin B". | ||
| * console.log( String.raw`A \notin B` ); // Prints "A \notin B". | ||
| * | ||
| * This means that if `<script` transforms into `<\u0073cript` _inside_ a raw string | ||
| * or tagged template literal which relies on its `.raw` property, the output of the | ||
| * code will be different after escaping. | ||
| * | ||
| * Example: | ||
| * | ||
| * console.log( String.raw`</script>` ); // Prematurely closes the SCRIPT element. | ||
| * console.log( String.raw`</\u0073cript>` ); // Prints "</\u0073cript". | ||
| * | ||
| * #### RegExp sources. | ||
| * | ||
| * The RegExp object exposes its raw source in a similar way to how tagged templates and raw | ||
| * strings do. Thankfully, because escape sequences are decoded when compiling the pattern, | ||
| * escaped RegExp patterns will match the same way as the plaintext sequences would. | ||
| * | ||
| * Example: | ||
| * | ||
| * true === /<script>/.test( '<script>' ); | ||
| * true === /<\u0073cript>/.test( '<script>' ); | ||
| * | ||
| * However, as with raw strings, any code which reads the source will see the escaped value | ||
| * instead of the decoded one. | ||
| * | ||
| * Example: | ||
| * | ||
| * console.log( /<script>/.source ); // Prints "<script>". | ||
| * console.log( /<\u0073cript>/.source ); // Prints "<\u0073cript>". | ||
| * | ||
| * #### Unsupported escaping. | ||
| * | ||
| * It is not possible to properly represent every possible JavaScript source file | ||
| * inside a SCRIPT element. As with CSS stylesheets, SVG images, and MathML, the | ||
| * only 100% reliable way to represent all possible inputs is to link to external | ||
| * files of the given content-type. | ||
| * | ||
| * In some cases it’s possible to manually prevent escaping issues. These are not | ||
| * automatically handled by this function because doing so would require a full | ||
| * JavaScript tokenizer. Consider the following example listing various ways to | ||
| * manually escape a closing script tag. | ||
| * | ||
| * Example: | ||
| * | ||
| * console.log( String.raw`</script>` ); // !!UNSAFE!! Will be escaped. | ||
| * console.log( String.raw`</\u0073cript>` ); // "</\u0073cript>" | ||
| * console.log( String.raw`</scr` + String.raw`ipt>` ); // "</script>" | ||
| * console.log( String.raw`</${"script"}>` ); // "</script>" | ||
| * console.log( '</scr' + 'ipt>' ); // "</script>" | ||
| * console.log( "\x3C/script>" ); // "</script>" | ||
| * console.log( "<\/script>" ); // "</script>" | ||
| * | ||
| * The following graph is a simplified interpretation of how HTML interprets the contents | ||
| * of a SCRIPT tag and identifies the closing tag. It is useful to understand what text | ||
| * is dangerous inside of a SCRIPT tag and why different approaches to escaping work. | ||
| * | ||
| * Open script | ||
| * │ | ||
| * ▼ | ||
| * ╔═════════════════════════════════════════╗ <!--(…)> | ||
| * ║ ║ (all dashes) | ||
| * ║ script ╟────────────────╮ | ||
| * ║ data ║ │ | ||
| * ╭───────────╢ ║ ◀──────────────╯ | ||
| * │ ╚═╤═══════════════════════════════════════╝ | ||
| * │ │ ▲ ▲ | ||
| * │ │ <!-- │ --> ╰─────╮ | ||
| * │ ▼ │ │ | ||
| * │ ┌─────────────────┴───────────────────────┐ │ | ||
| * │ </script¹ │ escaped │ │ | ||
| * │ └─┬─────────────────────────────┬─────────┘ │ | ||
| * │ │ ▲ │ │ --> | ||
| * │ │ </script¹ │ </script¹ │ <script¹ │ | ||
| * │ ▼ │ ▼ │ | ||
| * │ ╔══════════════╗ │ ┌───────────┐ │ | ||
| * │ ║ Close script ║ │ │ double │ │ | ||
| * ╰──────────▶║ ║ ╰───────────┤ escaped ├──╯ | ||
| * ╚══════════════╝ └───────────┘ | ||
| * | ||
| * ¹ = Case insensitive 'script' followed by one of ' \t\f\r\n/>', known | ||
| * as “tag-name-terminating characters.” This sequence forms the start | ||
| * of what could be a SCRIPT opening or closing tag. | ||
| * | ||
| * @see https://html.spec.whatwg.org/#restrictions-for-contents-of-script-elements | ||
| * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#specifications | ||
| * @see wp_html_api_script_element_escaping_diagram_source() | ||
| * | ||
| * @since 7.0.0 | ||
| * | ||
| * @param string $sourcecode Raw contents intended to be serialized into an HTML SCRIPT element. | ||
| * @return string Escaped form of input contents which will not lead to premature closing of the containing SCRIPT element. | ||
| */ | ||
| public static function escape_javascript_script_contents( string $sourcecode ): string { | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This was made public in bfa60cf. @westonruter asked about this new public method in #10639 (comment), and it was something I had planned to review. I'm going to make it private for now and we can consider opening it up in follow-up work. |
||
| $at = 0; | ||
| $was_at = 0; | ||
| $end = strlen( $sourcecode ); | ||
| $escaped = ''; | ||
|
|
||
| /* | ||
| * Replace all instances of the ASCII case-insensitive match of "<script" | ||
| * and "</script", when followed by whitespace or "/" or ">", by using a | ||
| * character replacement for the "s" (or the "S"). | ||
| */ | ||
| while ( $at < $end ) { | ||
| $tag_at = strpos( $sourcecode, '<', $at ); | ||
| if ( false === $tag_at ) { | ||
| break; | ||
| } | ||
|
|
||
| $tag_name_at = $tag_at + 1; | ||
| $has_closing_slash = $tag_name_at < $end && '/' === $sourcecode[ $tag_name_at ]; | ||
| $tag_name_at += $has_closing_slash ? 1 : 0; | ||
|
|
||
| if ( 0 !== substr_compare( $sourcecode, 'script', $tag_name_at, 6, true ) ) { | ||
| $at = $tag_at + 1; | ||
| continue; | ||
| } | ||
|
|
||
| if ( 1 !== strspn( $sourcecode, " \t\f\r\n/>", $tag_name_at + 6, 1 ) ) { | ||
| $at = $tag_name_at + 6; | ||
| continue; | ||
| } | ||
|
|
||
| $escaped .= substr( $sourcecode, $was_at, $tag_name_at - $was_at ); | ||
| $escaped .= 's' === $sourcecode[ $tag_name_at ] ? '\u0073' : '\u0053'; | ||
| $was_at = $tag_name_at + 1; | ||
| $at = $tag_name_at + 7; | ||
| } | ||
|
|
||
| if ( '' === $escaped ) { | ||
| return $sourcecode; | ||
| } | ||
|
|
||
| if ( $was_at < $end ) { | ||
| $escaped .= substr( $sourcecode, $was_at ); | ||
| } | ||
|
|
||
| return $escaped; | ||
| } | ||
|
|
||
| /** | ||
| * Updates or creates a new attribute on the currently matched tag with the passed value. | ||
| * | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.