Skip to content

Commit

Permalink
fix #2649: allow disabling escaping of </script>
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Dec 6, 2022
1 parent 0228284 commit 328ce12
Show file tree
Hide file tree
Showing 11 changed files with 108 additions and 91 deletions.
14 changes: 14 additions & 0 deletions CHANGELOG.md
Expand Up @@ -91,6 +91,20 @@

In addition, adding `assert { type: 'json' }` to an import statement now means esbuild will generate an error if the loader for the file is anything other than `json`, which is required by the import assertion specification.

* Provide a way to disable automatic escaping of `</script>` ([#2649](https://github.com/evanw/esbuild/issues/2649))

If you inject esbuild's output into a script tag in an HTML file, code containing the literal characters `</script>` will cause the tag to be ended early which will break the code:

```html
<script>
console.log("</script>");
</script>
```

To avoid this, esbuild automatically escapes these strings in generated JavaScript files (e.g. `"</script>"` becomes `"<\/script>"` instead). This also applies to `</style>` in generated CSS files. Previously this always happened and there wasn't a way to turn this off.

With this release, esbuild will now only do this if the `platform` setting is set to `browser` (the default value). Setting `platform` to `node` or `neutral` will disable this behavior. This behavior can also now be disabled with `--supported:inline-script=false` (for JS) and `--supported:inline-style=false` (for CSS).

* Throw an early error if decoded UTF-8 text isn't a `Uint8Array` ([#2532](https://github.com/evanw/esbuild/issues/2532))

If you run esbuild's JavaScript API in a broken JavaScript environment where `new TextEncoder().encode("") instanceof Uint8Array` is false, then esbuild's API will fail with a confusing serialization error message that makes it seem like esbuild has a bug even though the real problem is that the JavaScript environment itself is broken. This can happen when using the test framework called [Jest](https://jestjs.io/). With this release, esbuild's API will now throw earlier when it detects that the environment is unable to encode UTF-8 text correctly with an error message that makes it more clear that this is not a problem with esbuild.
Expand Down
11 changes: 11 additions & 0 deletions internal/bundler/bundler.go
Expand Up @@ -2216,6 +2216,17 @@ func applyOptionDefaults(options *config.Options) {
compat.ClassField|compat.ClassPrivateAccessor|compat.ClassPrivateBrandCheck|compat.ClassPrivateField|
compat.ClassPrivateMethod|compat.ClassPrivateStaticAccessor|compat.ClassPrivateStaticField|
compat.ClassPrivateStaticMethod|compat.ClassStaticBlocks|compat.ClassStaticField)

// If we're not building for the browser, automatically disable support for
// inline </script> and </style> tags if there aren't currently any overrides
if options.Platform != config.PlatformBrowser {
if !options.UnsupportedJSFeatureOverridesMask.Has(compat.InlineScript) {
options.UnsupportedJSFeatures |= compat.InlineScript
}
if !options.UnsupportedCSSFeatureOverridesMask.Has(compat.InlineStyle) {
options.UnsupportedCSSFeatures |= compat.InlineStyle
}
}
}

func fixInvalidUnsupportedJSFeatureOverrides(options *config.Options, implies compat.JSFeature, implied compat.JSFeature) {
Expand Down
15 changes: 8 additions & 7 deletions internal/bundler/linker.go
Expand Up @@ -5322,13 +5322,14 @@ func (c *linkerContext) generateChunkCSS(chunks []chunkInfo, chunkIndex int, chu
}

cssOptions := css_printer.Options{
MinifyWhitespace: c.options.MinifyWhitespace,
ASCIIOnly: c.options.ASCIIOnly,
LegalComments: c.options.LegalComments,
SourceMap: c.options.SourceMap,
AddSourceMappings: addSourceMappings,
InputSourceMap: inputSourceMap,
LineOffsetTables: lineOffsetTables,
MinifyWhitespace: c.options.MinifyWhitespace,
ASCIIOnly: c.options.ASCIIOnly,
LegalComments: c.options.LegalComments,
SourceMap: c.options.SourceMap,
UnsupportedFeatures: c.options.UnsupportedCSSFeatures,
AddSourceMappings: addSourceMappings,
InputSourceMap: inputSourceMap,
LineOffsetTables: lineOffsetTables,
}
compileResult.PrintResult = css_printer.Print(asts[i], cssOptions)
compileResult.sourceIndex = sourceIndex
Expand Down
11 changes: 2 additions & 9 deletions internal/compat/css_table.go
Expand Up @@ -4,7 +4,7 @@ type CSSFeature uint8

const (
HexRGBA CSSFeature = 1 << iota

InlineStyle
RebeccaPurple

// This feature includes all of the following:
Expand All @@ -20,20 +20,13 @@ const (

var StringToCSSFeature = map[string]CSSFeature{
"hex-rgba": HexRGBA,
"inline-style": InlineStyle,
"rebecca-purple": RebeccaPurple,
"modern-rgb-hsl": Modern_RGB_HSL,
"inset-property": InsetProperty,
"nesting": Nesting,
}

var CSSFeatureToString = map[CSSFeature]string{
HexRGBA: "hex-rgba",
RebeccaPurple: "rebecca-purple",
Modern_RGB_HSL: "modern-rgb-hsl",
InsetProperty: "inset-property",
Nesting: "nesting",
}

func (features CSSFeature) Has(feature CSSFeature) bool {
return (features & feature) != 0
}
Expand Down
57 changes: 3 additions & 54 deletions internal/compat/js_table.go
Expand Up @@ -81,6 +81,7 @@ const (
Hashbang
ImportAssertions
ImportMeta
InlineScript
LogicalAssignment
NestedRestBinding
NewTarget
Expand Down Expand Up @@ -135,6 +136,7 @@ var StringToJSFeature = map[string]JSFeature{
"hashbang": Hashbang,
"import-assertions": ImportAssertions,
"import-meta": ImportMeta,
"inline-script": InlineScript,
"logical-assignment": LogicalAssignment,
"nested-rest-binding": NestedRestBinding,
"new-target": NewTarget,
Expand All @@ -159,60 +161,6 @@ var StringToJSFeature = map[string]JSFeature{
"unicode-escapes": UnicodeEscapes,
}

var JSFeatureToString = map[JSFeature]string{
ArbitraryModuleNamespaceNames: "arbitrary-module-namespace-names",
ArraySpread: "array-spread",
Arrow: "arrow",
AsyncAwait: "async-await",
AsyncGenerator: "async-generator",
Bigint: "bigint",
Class: "class",
ClassField: "class-field",
ClassPrivateAccessor: "class-private-accessor",
ClassPrivateBrandCheck: "class-private-brand-check",
ClassPrivateField: "class-private-field",
ClassPrivateMethod: "class-private-method",
ClassPrivateStaticAccessor: "class-private-static-accessor",
ClassPrivateStaticField: "class-private-static-field",
ClassPrivateStaticMethod: "class-private-static-method",
ClassStaticBlocks: "class-static-blocks",
ClassStaticField: "class-static-field",
ConstAndLet: "const-and-let",
DefaultArgument: "default-argument",
Destructuring: "destructuring",
DynamicImport: "dynamic-import",
ExponentOperator: "exponent-operator",
ExportStarAs: "export-star-as",
ForAwait: "for-await",
ForOf: "for-of",
Generator: "generator",
Hashbang: "hashbang",
ImportAssertions: "import-assertions",
ImportMeta: "import-meta",
LogicalAssignment: "logical-assignment",
NestedRestBinding: "nested-rest-binding",
NewTarget: "new-target",
NodeColonPrefixImport: "node-colon-prefix-import",
NodeColonPrefixRequire: "node-colon-prefix-require",
NullishCoalescing: "nullish-coalescing",
ObjectAccessors: "object-accessors",
ObjectExtensions: "object-extensions",
ObjectRestSpread: "object-rest-spread",
OptionalCatchBinding: "optional-catch-binding",
OptionalChain: "optional-chain",
RegexpDotAllFlag: "regexp-dot-all-flag",
RegexpLookbehindAssertions: "regexp-lookbehind-assertions",
RegexpMatchIndices: "regexp-match-indices",
RegexpNamedCaptureGroups: "regexp-named-capture-groups",
RegexpStickyAndUnicodeFlags: "regexp-sticky-and-unicode-flags",
RegexpUnicodePropertyEscapes: "regexp-unicode-property-escapes",
RestArgument: "rest-argument",
TemplateLiteral: "template-literal",
TopLevelAwait: "top-level-await",
TypeofExoticObjectIsObject: "typeof-exotic-object-is-object",
UnicodeEscapes: "unicode-escapes",
}

func (features JSFeature) Has(feature JSFeature) bool {
return (features & feature) != 0
}
Expand Down Expand Up @@ -527,6 +475,7 @@ var jsTable = map[JSFeature]map[Engine][]versionRange{
Opera: {{start: v{51, 0, 0}}},
Safari: {{start: v{11, 1, 0}}},
},
InlineScript: {},
LogicalAssignment: {
Chrome: {{start: v{85, 0, 0}}},
Deno: {{start: v{1, 2, 0}}},
Expand Down
18 changes: 11 additions & 7 deletions internal/css_printer/css_printer.go
Expand Up @@ -6,6 +6,7 @@ import (
"unicode/utf8"

"github.com/evanw/esbuild/internal/ast"
"github.com/evanw/esbuild/internal/compat"
"github.com/evanw/esbuild/internal/config"
"github.com/evanw/esbuild/internal/css_ast"
"github.com/evanw/esbuild/internal/css_lexer"
Expand All @@ -32,11 +33,12 @@ type Options struct {
// us do binary search on to figure out what line a given AST node came from
LineOffsetTables []sourcemap.LineOffsetTable

MinifyWhitespace bool
ASCIIOnly bool
SourceMap config.SourceMap
AddSourceMappings bool
LegalComments config.LegalComments
UnsupportedFeatures compat.CSSFeature
MinifyWhitespace bool
ASCIIOnly bool
SourceMap config.SourceMap
AddSourceMappings bool
LegalComments config.LegalComments
}

type PrintResult struct {
Expand Down Expand Up @@ -268,7 +270,9 @@ func (p *printer) printRule(rule css_ast.Rule, indent int32, omitTrailingSemicol

func (p *printer) printIndentedComment(indent int32, text string) {
// Avoid generating a comment containing the character sequence "</style"
text = helpers.EscapeClosingTag(text, "/style")
if !p.options.UnsupportedFeatures.Has(compat.InlineStyle) {
text = helpers.EscapeClosingTag(text, "/style")
}

// Re-indent multi-line comments
for {
Expand Down Expand Up @@ -580,7 +584,7 @@ func (p *printer) printQuotedWithQuote(text string, quote byte) {

case '/':
// Avoid generating the sequence "</style" in CSS code
if i >= 1 && text[i-1] == '<' && i+6 <= len(text) && strings.EqualFold(text[i+1:i+6], "style") {
if !p.options.UnsupportedFeatures.Has(compat.InlineStyle) && i >= 1 && text[i-1] == '<' && i+6 <= len(text) && strings.EqualFold(text[i+1:i+6], "style") {
escape = escapeBackslash
}

Expand Down
2 changes: 1 addition & 1 deletion internal/js_parser/js_parser.go
Expand Up @@ -12654,7 +12654,7 @@ func (p *parser) visitExprInOut(expr js_ast.Expr, in exprIn) (js_ast.Expr, exprO

// Lower tagged template literals that include "</script"
// since we won't be able to escape it without lowering it
if !shouldLowerTemplateLiteral && e.TagOrNil.Data != nil {
if !p.options.unsupportedJSFeatures.Has(compat.InlineScript) && !shouldLowerTemplateLiteral && e.TagOrNil.Data != nil {
if containsClosingScriptTag(e.HeadRaw) {
shouldLowerTemplateLiteral = true
} else {
Expand Down
8 changes: 5 additions & 3 deletions internal/js_printer/js_printer.go
Expand Up @@ -117,7 +117,7 @@ func (p *printer) printUnquotedUTF16(text []uint16, quote rune) {

case '/':
// Avoid generating the sequence "</script" in JS code
if i >= 2 && text[i-2] == '<' && i+6 <= len(text) {
if !p.options.UnsupportedFeatures.Has(compat.InlineScript) && i >= 2 && text[i-2] == '<' && i+6 <= len(text) {
script := "script"
matches := true
for j := 0; j < 6; j++ {
Expand Down Expand Up @@ -2378,7 +2378,7 @@ func (p *printer) printExpr(expr js_ast.Expr, level js_ast.L, flags printExprFla
n := len(buffer)

// Avoid forming a single-line comment or "</script" sequence
if n > 0 {
if !p.options.UnsupportedFeatures.Has(compat.InlineScript) && n > 0 {
if last := buffer[n-1]; last == '/' || (last == '<' && len(e.Value) >= 7 && strings.EqualFold(e.Value[:7], "/script")) {
p.print(" ")
}
Expand Down Expand Up @@ -3053,7 +3053,9 @@ func (p *printer) printIf(s *js_ast.SIf) {

func (p *printer) printIndentedComment(text string) {
// Avoid generating a comment containing the character sequence "</script"
text = helpers.EscapeClosingTag(text, "/script")
if !p.options.UnsupportedFeatures.Has(compat.InlineScript) {
text = helpers.EscapeClosingTag(text, "/script")
}

if strings.HasPrefix(text, "/*") {
// Re-indent multi-line comments
Expand Down
10 changes: 4 additions & 6 deletions pkg/api/api_impl.go
Expand Up @@ -97,11 +97,9 @@ func validatePathTemplate(template string) []config.PathTemplate {
return parts
}

func validatePlatform(value Platform, defaultPlatform config.Platform) config.Platform {
func validatePlatform(value Platform) config.Platform {
switch value {
case PlatformDefault:
return defaultPlatform
case PlatformBrowser:
case PlatformDefault, PlatformBrowser:
return config.PlatformBrowser
case PlatformNode:
return config.PlatformNode
Expand Down Expand Up @@ -950,7 +948,7 @@ func rebuildImpl(
bannerJS, bannerCSS := validateBannerOrFooter(log, "banner", buildOpts.Banner)
footerJS, footerCSS := validateBannerOrFooter(log, "footer", buildOpts.Footer)
minify := buildOpts.MinifyWhitespace && buildOpts.MinifyIdentifiers && buildOpts.MinifySyntax
platform := validatePlatform(buildOpts.Platform, config.PlatformBrowser)
platform := validatePlatform(buildOpts.Platform)
defines, injectedDefines := validateDefines(log, buildOpts.Define, buildOpts.Pure, platform, minify, buildOpts.Drop)
mangleCache := cloneMangleCache(log, buildOpts.MangleCache)
options := config.Options{
Expand Down Expand Up @@ -1485,7 +1483,7 @@ func transformImpl(input string, transformOpts TransformOptions) TransformResult
// Convert and validate the transformOpts
targetFromAPI, jsFeatures, cssFeatures, targetEnv := validateFeatures(log, transformOpts.Target, transformOpts.Engines)
jsOverrides, jsMask, cssOverrides, cssMask := validateSupported(log, transformOpts.Supported)
platform := validatePlatform(transformOpts.Platform, config.PlatformNeutral)
platform := validatePlatform(transformOpts.Platform)
defines, injectedDefines := validateDefines(log, transformOpts.Define, transformOpts.Pure, platform, false /* minify */, transformOpts.Drop)
mangleCache := cloneMangleCache(log, transformOpts.MangleCache)
options := config.Options{
Expand Down
9 changes: 5 additions & 4 deletions scripts/compat-table.js
Expand Up @@ -263,6 +263,11 @@ mergeVersions('TypeofExoticObjectIsObject', {
safari0: true,
})

// If you want to embed the output directly in HTML, then closing HTML tags must
// be marked as unsupported. Doing this escapes "</script>" in JS and "</style>"
// in CSS. Otherwise the containing HTML tag will be ended early.
mergeVersions('InlineScript', {})

// This is a special case. Node added support for it to both v12.20+ and v13.2+
// so the range is inconveniently discontiguous. Sources:
//
Expand Down Expand Up @@ -433,10 +438,6 @@ var StringToJSFeature = map[string]JSFeature{
${simpleMap(Object.keys(versions).sort().map(x => [`"${jsFeatureString(x)}"`, x]))}
}
var JSFeatureToString = map[JSFeature]string{
${simpleMap(Object.keys(versions).sort().map(x => [x, `"${jsFeatureString(x)}"`]))}
}
func (features JSFeature) Has(feature JSFeature) bool {
\treturn (features & feature) != 0
}
Expand Down
44 changes: 44 additions & 0 deletions scripts/js-api-tests.js
Expand Up @@ -5212,6 +5212,50 @@ let transformTests = {
assert.strictEqual(fromPromiseResolve(code4), `Promise.resolve().then(function(){return __toESM(require(foo))});\n`)
},

async inlineScript({ esbuild }) {
let p
assert.strictEqual((await esbuild.transform(`x = '</script>'`, {})).code, `x = "<\\/script>";\n`)
assert.strictEqual((await esbuild.transform(`x = '</script> inline'`, { supported: { 'inline-script': true } })).code, `x = "<\\/script> inline";\n`)
assert.strictEqual((await esbuild.transform(`x = '</script> noinline'`, { supported: { 'inline-script': false } })).code, `x = "</script> noinline";\n`)

p = { platform: 'browser' }
assert.strictEqual((await esbuild.transform(`x = '</script> browser'`, { ...p })).code, `x = "<\\/script> browser";\n`)
assert.strictEqual((await esbuild.transform(`x = '</script> browser inline'`, { ...p, supported: { 'inline-script': true } })).code, `x = "<\\/script> browser inline";\n`)
assert.strictEqual((await esbuild.transform(`x = '</script> browser noinline'`, { ...p, supported: { 'inline-script': false } })).code, `x = "</script> browser noinline";\n`)

p = { platform: 'node' }
assert.strictEqual((await esbuild.transform(`x = '</script> node'`, { ...p })).code, `x = "</script> node";\n`)
assert.strictEqual((await esbuild.transform(`x = '</script> node inline'`, { ...p, supported: { 'inline-script': true } })).code, `x = "<\\/script> node inline";\n`)
assert.strictEqual((await esbuild.transform(`x = '</script> node noinline'`, { ...p, supported: { 'inline-script': false } })).code, `x = "</script> node noinline";\n`)

p = { platform: 'neutral' }
assert.strictEqual((await esbuild.transform(`x = '</script> neutral'`, { ...p })).code, `x = "</script> neutral";\n`)
assert.strictEqual((await esbuild.transform(`x = '</script> neutral inline'`, { ...p, supported: { 'inline-script': true } })).code, `x = "<\\/script> neutral inline";\n`)
assert.strictEqual((await esbuild.transform(`x = '</script> neutral noinline'`, { ...p, supported: { 'inline-script': false } })).code, `x = "</script> neutral noinline";\n`)
},

async inlineStyle({ esbuild }) {
let p = { loader: 'css' }
assert.strictEqual((await esbuild.transform(`x { y: '</style>' }`, { ...p })).code, `x {\n y: "<\\/style>";\n}\n`)
assert.strictEqual((await esbuild.transform(`x { y: '</style> inline' }`, { ...p, supported: { 'inline-style': true } })).code, `x {\n y: "<\\/style> inline";\n}\n`)
assert.strictEqual((await esbuild.transform(`x { y: '</style> noinline' }`, { ...p, supported: { 'inline-style': false } })).code, `x {\n y: "</style> noinline";\n}\n`)

p = { loader: 'css', platform: 'browser' }
assert.strictEqual((await esbuild.transform(`x { y: '</style> browser' }`, { ...p })).code, `x {\n y: "<\\/style> browser";\n}\n`)
assert.strictEqual((await esbuild.transform(`x { y: '</style> browser inline' }`, { ...p, supported: { 'inline-style': true } })).code, `x {\n y: "<\\/style> browser inline";\n}\n`)
assert.strictEqual((await esbuild.transform(`x { y: '</style> browser noinline' }`, { ...p, supported: { 'inline-style': false } })).code, `x {\n y: "</style> browser noinline";\n}\n`)

p = { loader: 'css', platform: 'node' }
assert.strictEqual((await esbuild.transform(`x { y: '</style> node' }`, { ...p })).code, `x {\n y: "</style> node";\n}\n`)
assert.strictEqual((await esbuild.transform(`x { y: '</style> node inline' }`, { ...p, supported: { 'inline-style': true } })).code, `x {\n y: "<\\/style> node inline";\n}\n`)
assert.strictEqual((await esbuild.transform(`x { y: '</style> node noinline' }`, { ...p, supported: { 'inline-style': false } })).code, `x {\n y: "</style> node noinline";\n}\n`)

p = { loader: 'css', platform: 'neutral' }
assert.strictEqual((await esbuild.transform(`x { y: '</style> neutral' }`, { ...p })).code, `x {\n y: "</style> neutral";\n}\n`)
assert.strictEqual((await esbuild.transform(`x { y: '</style> neutral inline' }`, { ...p, supported: { 'inline-style': true } })).code, `x {\n y: "<\\/style> neutral inline";\n}\n`)
assert.strictEqual((await esbuild.transform(`x { y: '</style> neutral noinline' }`, { ...p, supported: { 'inline-style': false } })).code, `x {\n y: "</style> neutral noinline";\n}\n`)
},

async typeofEqualsUndefinedTarget({ esbuild }) {
assert.strictEqual((await esbuild.transform(`a = typeof b !== 'undefined'`, { minify: true })).code, `a=typeof b<"u";\n`)
assert.strictEqual((await esbuild.transform(`a = typeof b !== 'undefined'`, { minify: true, target: 'es2020' })).code, `a=typeof b<"u";\n`)
Expand Down

0 comments on commit 328ce12

Please sign in to comment.