From 4780075ace9f5f59b61018326ec57da624a7d7a2 Mon Sep 17 00:00:00 2001 From: Evan Wallace Date: Thu, 14 Mar 2024 15:30:36 -0400 Subject: [PATCH] fix #3700: json loader preserves `__proto__` keys --- CHANGELOG.md | 30 +++++++++++++ internal/bundler/bundler.go | 4 +- internal/bundler_tests/bundler_loader_test.go | 43 +++++++++++++++++++ .../snapshots/snapshots_loader.txt | 24 +++++++++++ internal/js_parser/json_parser.go | 14 +++++- scripts/end-to-end-tests.js | 37 ++++++++++++++++ 6 files changed, 149 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f0bef64f9e..58ceaf2bce3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,36 @@ }(); ``` +* JSON loader now preserves `__proto__` properties ([#3700](https://github.com/evanw/esbuild/issues/3700)) + + Copying JSON source code into a JavaScript file will change its meaning if a JSON object contains the `__proto__` key. A literal `__proto__` property in a JavaScript object literal sets the prototype of the object instead of adding a property named `__proto__`, while a literal `__proto__` property in a JSON object literal just adds a property named `__proto__`. With this release, esbuild will now work around this problem by converting JSON to JavaScript with a computed property key in this case: + + ```js + // Original code + import data from 'data:application/json,{"__proto__":{"fail":true}}' + if (Object.getPrototypeOf(data)?.fail) throw 'fail' + + // Old output (with --bundle) + (() => { + // + var json_proto_fail_true_default = { __proto__: { fail: true } }; + + // entry.js + if (Object.getPrototypeOf(json_proto_fail_true_default)?.fail) + throw "fail"; + })(); + + // New output (with --bundle) + (() => { + // + var json_proto_fail_true_default = { ["__proto__"]: { fail: true } }; + + // example.mjs + if (Object.getPrototypeOf(json_proto_fail_true_default)?.fail) + throw "fail"; + })(); + ``` + * Improve dead code removal of `switch` statements ([#3659](https://github.com/evanw/esbuild/issues/3659)) With this release, esbuild will now remove `switch` statements in branches when minifying if they are known to never be evaluated: diff --git a/internal/bundler/bundler.go b/internal/bundler/bundler.go index ecabe1c8d78..e267f1ee5d1 100644 --- a/internal/bundler/bundler.go +++ b/internal/bundler/bundler.go @@ -244,7 +244,9 @@ func parseFile(args parseArgs) { result.ok = true case config.LoaderJSON, config.LoaderWithTypeJSON: - expr, ok := args.caches.JSONCache.Parse(args.log, source, js_parser.JSONOptions{}) + expr, ok := args.caches.JSONCache.Parse(args.log, source, js_parser.JSONOptions{ + UnsupportedJSFeatures: args.options.UnsupportedJSFeatures, + }) ast := js_parser.LazyExportAST(args.log, source, js_parser.OptionsFromConfig(&args.options), expr, "") if loader == config.LoaderWithTypeJSON { // The exports kind defaults to "none", in which case the linker picks diff --git a/internal/bundler_tests/bundler_loader_test.go b/internal/bundler_tests/bundler_loader_test.go index f311da755b8..9346392a1e9 100644 --- a/internal/bundler_tests/bundler_loader_test.go +++ b/internal/bundler_tests/bundler_loader_test.go @@ -1632,3 +1632,46 @@ func TestLoaderBundleWithTypeJSONOnlyDefaultExport(t *testing.T) { `, }) } + +func TestLoaderJSONPrototype(t *testing.T) { + loader_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/entry.js": ` + import data from "./data.json" + console.log(data) + `, + "/data.json": `{ + "": "The property below should be converted to a computed property:", + "__proto__": { "foo": "bar" } + }`, + }, + entryPaths: []string{"/entry.js"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputFile: "/out.js", + MinifySyntax: true, + }, + }) +} + +func TestLoaderJSONPrototypeES5(t *testing.T) { + loader_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/entry.js": ` + import data from "./data.json" + console.log(data) + `, + "/data.json": `{ + "": "The property below should NOT be converted to a computed property for ES5:", + "__proto__": { "foo": "bar" } + }`, + }, + entryPaths: []string{"/entry.js"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputFile: "/out.js", + MinifySyntax: true, + UnsupportedJSFeatures: es(5), + }, + }) +} diff --git a/internal/bundler_tests/snapshots/snapshots_loader.txt b/internal/bundler_tests/snapshots/snapshots_loader.txt index e9851d9c261..8bb4c4bb1d9 100644 --- a/internal/bundler_tests/snapshots/snapshots_loader.txt +++ b/internal/bundler_tests/snapshots/snapshots_loader.txt @@ -899,6 +899,30 @@ TestLoaderJSONNoBundleIIFE require_test(); })(); +================================================================================ +TestLoaderJSONPrototype +---------- /out.js ---------- +// data.json +var data_default = { + "": "The property below should be converted to a computed property:", + ["__proto__"]: { foo: "bar" } +}; + +// entry.js +console.log(data_default); + +================================================================================ +TestLoaderJSONPrototypeES5 +---------- /out.js ---------- +// data.json +var data_default = { + "": "The property below should NOT be converted to a computed property for ES5:", + __proto__: { foo: "bar" } +}; + +// entry.js +console.log(data_default); + ================================================================================ TestLoaderJSONSharedWithMultipleEntriesIssue413 ---------- /out/a.js ---------- diff --git a/internal/js_parser/json_parser.go b/internal/js_parser/json_parser.go index 8d081ab0945..ab1fdd89127 100644 --- a/internal/js_parser/json_parser.go +++ b/internal/js_parser/json_parser.go @@ -3,6 +3,7 @@ package js_parser import ( "fmt" + "github.com/evanw/esbuild/internal/compat" "github.com/evanw/esbuild/internal/helpers" "github.com/evanw/esbuild/internal/js_ast" "github.com/evanw/esbuild/internal/js_lexer" @@ -142,6 +143,14 @@ func (p *jsonParser) parseExpr() js_ast.Expr { Key: key, ValueOrNil: value, } + + // The key "__proto__" must not be a string literal in JavaScript because + // that actually modifies the prototype of the object. This can be + // avoided by using a computed property key instead of a string literal. + if helpers.UTF16EqualsString(keyString, "__proto__") && !p.options.UnsupportedJSFeatures.Has(compat.ObjectExtensions) { + property.Flags |= js_ast.PropertyIsComputed + } + properties = append(properties, property) } @@ -163,8 +172,9 @@ func (p *jsonParser) parseExpr() js_ast.Expr { } type JSONOptions struct { - Flavor js_lexer.JSONFlavor - ErrorSuffix string + UnsupportedJSFeatures compat.JSFeature + Flavor js_lexer.JSONFlavor + ErrorSuffix string } func ParseJSON(log logger.Log, source logger.Source, options JSONOptions) (result js_ast.Expr, ok bool) { diff --git a/scripts/end-to-end-tests.js b/scripts/end-to-end-tests.js index bbfbd320096..721bfe1dc0d 100644 --- a/scripts/end-to-end-tests.js +++ b/scripts/end-to-end-tests.js @@ -3528,6 +3528,43 @@ for (const minify of [[], ['--minify-syntax']]) { `, }), ) + + // https://github.com/evanw/esbuild/issues/3700 + tests.push( + test(['in.js', '--bundle', '--outfile=node.js'].concat(minify), { + 'in.js': ` + import imported from './data.json' + const native = JSON.parse(\`{ + "hello": "world", + "__proto__": { + "sky": "universe" + } + }\`) + const literal1 = { + "hello": "world", + "__proto__": { + "sky": "universe" + } + } + const literal2 = { + "hello": "world", + ["__proto__"]: { + "sky": "universe" + } + } + if (Object.getPrototypeOf(native)?.sky) throw 'fail: native' + if (!Object.getPrototypeOf(literal1)?.sky) throw 'fail: literal1' + if (Object.getPrototypeOf(literal2)?.sky) throw 'fail: literal2' + if (Object.getPrototypeOf(imported)?.sky) throw 'fail: imported' + `, + 'data.json': `{ + "hello": "world", + "__proto__": { + "sky": "universe" + } + }`, + }), + ) } // Test minification of top-level symbols