Deprecate unescaped HTML inside of expressions (#2489)

* feat: implement automatic escaping

* feat: deprecate automatic escaping

* fix: cast unescapeHTML as string

* fix: slot fallback behavior

* fix: unescaped content

* Update escape.ts

* Update escape.ts

* feat: update internal components to use `set:html`

* chore: update compiler

* chore: update changeset
This commit is contained in:
Nate Moore 2022-01-31 16:13:22 -06:00 committed by GitHub
parent b9da87a108
commit 618a16f59d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 74 additions and 31 deletions

View file

@ -0,0 +1,8 @@
---
'astro': minor
---
Add support for the `set:html` and `set:text` directives.
With the introduction of these directives, unescaped HTML content in expressions is now deprecated. Please migrate to `set:html` in order to continue injecting unescaped HTML in future versions of Astro—you can use `<Fragment set:html={content}>` to avoid a wrapper element. `set:text` allows you to opt-in to escaping now, but it will soon become the default.

View file

@ -2,4 +2,4 @@
'astro': patch 'astro': patch
--- ---
Bug fix for define:vars with the --experimental-static-build flag Bug fix for `define:vars` with the --experimental-static-build flag

View file

@ -47,4 +47,4 @@ const _html = highlighter.codeToHtml(code, lang);
const html = repairShikiTheme(_html); const html = repairShikiTheme(_html);
--- ---
{html} <Fragment set:html={html} />

View file

@ -41,4 +41,4 @@ if (content) {
html = htmlContent; html = htmlContent;
--- ---
{html ? html : <slot />} {html ? <Fragment set:html={html} /> : <slot />}

View file

@ -46,4 +46,4 @@ if (grammar) {
} }
--- ---
<pre class={[className, classLanguage].join(' ')}><code class={classLanguage}>{html}</code></pre> <pre class={[className, classLanguage].join(' ')}><code class={classLanguage}><Fragment set:html={html} /></code></pre>

View file

@ -55,7 +55,7 @@
"test:match": "mocha --timeout 15000 -g" "test:match": "mocha --timeout 15000 -g"
}, },
"dependencies": { "dependencies": {
"@astrojs/compiler": "^0.10.0", "@astrojs/compiler": "^0.10.1",
"@astrojs/language-server": "^0.8.6", "@astrojs/language-server": "^0.8.6",
"@astrojs/markdown-remark": "^0.6.0", "@astrojs/markdown-remark": "^0.6.0",
"@astrojs/prism": "0.4.0", "@astrojs/prism": "0.4.0",

View file

@ -1,3 +1,34 @@
const entities = { '"': 'quot', '&': 'amp', "'": 'apos', '<': 'lt', '>': 'gt' } as const; const entities = { '"': 'quot', '&': 'amp', "'": 'apos', '<': 'lt', '>': 'gt' } as const;
export const escapeHTML = (string: any) => string.replace(/["'&<>]/g, (char: keyof typeof entities) => '&' + entities[char] + ';'); const warned = new Set<string>();
export const escapeHTML = (string: any, { deprecated = false }: { deprecated?: boolean } = {}) => {
const escaped = string.replace(/["'&<>]/g, (char: keyof typeof entities) => '&' + entities[char] + ';');
if (!deprecated) return escaped;
if (warned.has(string) || !string.match(/[&<>]/g)) return string;
// eslint-disable-next-line no-console
console.warn(`Unescaped HTML content found inside expression!
The next minor version of Astro will automatically escape all
expression content. Please use the \`set:html\` directive.
Expression content:
${string}`);
warned.add(string);
// Return unescaped content for now. To be removed.
return string;
}
/**
* RawString is a "blessed" version of String
* that is not subject to escaping.
*/
export class UnescapedString extends String {}
/**
* unescapeHTML marks a string as raw, unescaped HTML.
* This should only be generated internally, not a public API.
*
* Need to cast the return value `as unknown as string` so TS doesn't yell at us.
*/
export const unescapeHTML = (str: any) => new UnescapedString(str) as unknown as string;

View file

@ -4,9 +4,11 @@ import type { AstroGlobalPartial, SSRResult, SSRElement } from '../../@types/ast
import shorthash from 'shorthash'; import shorthash from 'shorthash';
import { extractDirectives, generateHydrateScript } from './hydration.js'; import { extractDirectives, generateHydrateScript } from './hydration.js';
import { serializeListValue } from './util.js'; import { serializeListValue } from './util.js';
export { createMetadata } from './metadata.js'; import { escapeHTML, UnescapedString, unescapeHTML } from './escape.js';
export { escapeHTML } from './escape.js';
export type { Metadata } from './metadata'; export type { Metadata } from './metadata';
export { createMetadata } from './metadata.js';
export { escapeHTML, unescapeHTML } from './escape.js';
const voidElementNames = /^(area|base|br|col|command|embed|hr|img|input|keygen|link|meta|param|source|track|wbr)$/i; const voidElementNames = /^(area|base|br|col|command|embed|hr|img|input|keygen|link|meta|param|source|track|wbr)$/i;
@ -19,22 +21,24 @@ const voidElementNames = /^(area|base|br|col|command|embed|hr|img|input|keygen|l
// Or maybe type UserValue = any; ? // Or maybe type UserValue = any; ?
async function _render(child: any): Promise<any> { async function _render(child: any): Promise<any> {
child = await child; child = await child;
if (Array.isArray(child)) { if (child instanceof UnescapedString) {
return (await Promise.all(child.map((value) => _render(value)))).join(''); return child;
} else if (Array.isArray(child)) {
return unescapeHTML((await Promise.all(child.map((value) => _render(value)))).join(''));
} else if (typeof child === 'function') { } else if (typeof child === 'function') {
// Special: If a child is a function, call it automatically. // Special: If a child is a function, call it automatically.
// This lets you do {() => ...} without the extra boilerplate // This lets you do {() => ...} without the extra boilerplate
// of wrapping it in a function and calling it. // of wrapping it in a function and calling it.
return _render(child()); return _render(child());
} else if (typeof child === 'string') { } else if (typeof child === 'string') {
return child; return escapeHTML(child, { deprecated: true });
} else if (!child && child !== 0) { } else if (!child && child !== 0) {
// do nothing, safe to ignore falsey values. // do nothing, safe to ignore falsey values.
} }
// Add a comment explaining why each of these are needed. // Add a comment explaining why each of these are needed.
// Maybe create clearly named function for what this is doing. // Maybe create clearly named function for what this is doing.
else if (child instanceof AstroComponent || Object.prototype.toString.call(child) === '[object AstroComponent]') { else if (child instanceof AstroComponent || Object.prototype.toString.call(child) === '[object AstroComponent]') {
return await renderAstroComponent(child); return unescapeHTML(await renderAstroComponent(child));
} else { } else {
return child; return child;
} }
@ -62,7 +66,7 @@ export class AstroComponent {
const html = htmlParts[i]; const html = htmlParts[i];
const expression = expressions[i]; const expression = expressions[i];
yield _render(html); yield _render(unescapeHTML(html));
yield _render(expression); yield _render(expression);
} }
} }
@ -88,7 +92,7 @@ export function createComponent(cb: AstroComponentFactory) {
export async function renderSlot(_result: any, slotted: string, fallback?: any) { export async function renderSlot(_result: any, slotted: string, fallback?: any) {
if (slotted) { if (slotted) {
return _render(slotted); return await _render(slotted);
} }
return fallback; return fallback;
} }
@ -122,12 +126,12 @@ export async function renderComponent(result: SSRResult, displayName: string, Co
const children = await renderSlot(result, slots?.default); const children = await renderSlot(result, slots?.default);
if (Component === Fragment) { if (Component === Fragment) {
return children; return unescapeHTML(children);
} }
if (Component && (Component as any).isAstroComponentFactory) { if (Component && (Component as any).isAstroComponentFactory) {
const output = await renderToString(result, Component as any, _props, slots); const output = await renderToString(result, Component as any, _props, slots);
return output; return unescapeHTML(output);
} }
if (Component === null && !_props['client:only']) { if (Component === null && !_props['client:only']) {
@ -233,7 +237,7 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr
// as a string and the user is responsible for adding a script tag for the component definition. // as a string and the user is responsible for adding a script tag for the component definition.
if (!html && typeof Component === 'string') { if (!html && typeof Component === 'string') {
html = await renderAstroComponent( html = await renderAstroComponent(
await render`<${Component}${spreadAttributes(props)}${(children == null || children == '') && voidElementNames.test(Component) ? `/>` : `>${children}</${Component}>`}` await render`<${Component}${spreadAttributes(props)}${unescapeHTML((children == null || children == '') && voidElementNames.test(Component) ? `/>` : `>${children}</${Component}>`)}`
); );
} }
@ -248,7 +252,7 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr
} }
if (!hydration) { if (!hydration) {
return html.replace(/\<\/?astro-fragment\>/g, ''); return unescapeHTML(html.replace(/\<\/?astro-fragment\>/g, ''));
} }
// Include componentExport name and componentUrl in hash to dedupe identical islands // Include componentExport name and componentUrl in hash to dedupe identical islands
@ -258,7 +262,7 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr
// INVESTIGATE: This will likely be a problem in streaming because the `<head>` will be gone at this point. // INVESTIGATE: This will likely be a problem in streaming because the `<head>` will be gone at this point.
result.scripts.add(await generateHydrateScript({ renderer, result, astroId, props }, metadata as Required<AstroComponentMetadata>)); result.scripts.add(await generateHydrateScript({ renderer, result, astroId, props }, metadata as Required<AstroComponentMetadata>));
return `<astro-root uid="${astroId}">${html ?? ''}</astro-root>`; return unescapeHTML(`<astro-root uid="${astroId}">${html ?? ''}</astro-root>`);
} }
/** Create the Astro.fetchContent() runtime function. */ /** Create the Astro.fetchContent() runtime function. */
@ -336,14 +340,14 @@ Make sure to use the static attribute syntax (\`${key}={value}\`) instead of the
// support "class" from an expression passed into an element (#782) // support "class" from an expression passed into an element (#782)
if (key === 'class:list') { if (key === 'class:list') {
return ` ${key.slice(0, -5)}="${toAttributeString(serializeListValue(value))}"`; return unescapeHTML(` ${key.slice(0, -5)}="${toAttributeString(serializeListValue(value))}"`);
} }
// Boolean only needs the key // Boolean only needs the key
if (value === true && key.startsWith('data-')) { if (value === true && key.startsWith('data-')) {
return ` ${key}`; return unescapeHTML(` ${key}`);
} else { } else {
return ` ${key}="${toAttributeString(value)}"`; return unescapeHTML(` ${key}="${toAttributeString(value)}"`);
} }
} }
@ -353,7 +357,7 @@ export function spreadAttributes(values: Record<any, any>) {
for (const [key, value] of Object.entries(values)) { for (const [key, value] of Object.entries(values)) {
output += addAttribute(value, key); output += addAttribute(value, key);
} }
return output; return unescapeHTML(output);
} }
// Adds CSS variables to an inline style tag // Adds CSS variables to an inline style tag
@ -378,7 +382,7 @@ export function defineScriptVars(vars: Record<any, any>) {
export async function renderToString(result: SSRResult, componentFactory: AstroComponentFactory, props: any, children: any) { export async function renderToString(result: SSRResult, componentFactory: AstroComponentFactory, props: any, children: any) {
const Component = await componentFactory(result, props, children); const Component = await componentFactory(result, props, children);
let template = await renderAstroComponent(Component); let template = await renderAstroComponent(Component);
return template; return unescapeHTML(template);
} }
// Filter out duplicate elements in our set // Filter out duplicate elements in our set
@ -431,15 +435,15 @@ export async function renderPage(result: SSRResult, Component: AstroComponentFac
} }
export async function renderAstroComponent(component: InstanceType<typeof AstroComponent>) { export async function renderAstroComponent(component: InstanceType<typeof AstroComponent>) {
let template = ''; let template = [];
for await (const value of component) { for await (const value of component) {
if (value || value === 0) { if (value || value === 0) {
template += value; template.push(value);
} }
} }
return template; return unescapeHTML(await _render(template));
} }
export async function renderHTMLElement(result: SSRResult, constructor: typeof HTMLElement, props: any, slots: any) { export async function renderHTMLElement(result: SSRResult, constructor: typeof HTMLElement, props: any, slots: any) {

View file

@ -130,10 +130,10 @@
jsonpointer "^5.0.0" jsonpointer "^5.0.0"
leven "^3.1.0" leven "^3.1.0"
"@astrojs/compiler@^0.10.0": "@astrojs/compiler@^0.10.1":
version "0.10.0" version "0.10.1"
resolved "https://registry.yarnpkg.com/@astrojs/compiler/-/compiler-0.10.0.tgz#6ba2707bf9a91017fd72fb46b1d7865036b71c09" resolved "https://registry.yarnpkg.com/@astrojs/compiler/-/compiler-0.10.1.tgz#69df1a7e4150c1b0b255154ae716dbc8b24c5dd4"
integrity sha512-TbeuITyhRGlQowipNX7Q8o5QczVjSxlE68xKh7i1swTxUhM8K/cnCPSyzTsWiuFWY4C5PDI1GREUaD3BHYfzqQ== integrity sha512-SUp5auq6jcLmxyOx8Ovd3ebvwR5wnuqsbIi77Ze/ua3+GMcR++rOWiyqyql887U2ajZMwJfHatOwQl67P7o5gg==
dependencies: dependencies:
typescript "^4.3.5" typescript "^4.3.5"