Compare commits

..

8 Commits

Author SHA1 Message Date
Nils Knappmeier f637dc76bd remove old aws-sdk 2023-08-06 01:20:58 +02:00
Nils Knappmeier e8299e0485 generate file list after publish 2023-08-06 01:17:42 +02:00
Nils Knappmeier 7989a5e9f9 function to upload a file list 2023-08-05 18:26:51 +02:00
Nils Knappmeier bde5506b10 more functions that help uploading a file listing. 2023-08-05 18:09:54 +02:00
Nils Knappmeier 6118a3c829 fixup: revert accidental webpack-config changes 2023-08-05 15:42:32 +02:00
Nils Knappmeier c5e5cad579 implement publish workflow 2023-08-05 15:11:00 +02:00
Nils Knappmeier 1c75903eaf add delete and upload functions 2023-08-05 13:57:00 +02:00
Nils Knappmeier ab920cc1ce wip: refactor s3 access and generate file listing on build 2023-08-05 00:11:49 +02:00
54 changed files with 18119 additions and 6576 deletions
+9 -9
View File
@@ -12,10 +12,10 @@ jobs:
runs-on: 'ubuntu-latest'
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v2
with:
node-version: '16'
@@ -33,16 +33,16 @@ jobs:
matrix:
operating-system: ['ubuntu-latest', 'windows-latest']
# https://nodejs.org/en/about/releases/
node-version: ['16', '18', '20', '22']
node-version: ['10', '12', '14', '16', '18', '20']
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
submodules: true
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
@@ -54,7 +54,7 @@ jobs:
- name: Test (Integration)
# https://github.com/webpack/webpack/issues/14532
if: ${{ matrix.node-version == '16' }}
if: ${{ matrix.node-version != '18' && matrix.node-version != '20' }}
run: |
cd ./tests/integration/rollup-test && ./test.sh && cd -
cd ./tests/integration/webpack-babel-test && ./test.sh && cd -
@@ -62,15 +62,15 @@ jobs:
browser:
name: Test (Browser)
runs-on: 'ubuntu-22.04'
runs-on: 'ubuntu-20.04'
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
submodules: true
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v2
with:
node-version: '16'
-1
View File
@@ -15,6 +15,5 @@ node_modules
lib/handlebars/compiler/parser.js
/coverage/
/dist/
/test-results/
/tests/integration/*/dist/
/spec/tmp/*
+4 -8
View File
@@ -132,11 +132,9 @@ A full release via Docker may be completed with the following:
# Generate config for yo generator-release:
# https://github.com/kpdecker/generator-release#example
# You have to add a valid GitHub access token! (Used for reading issues and pull requests.)
RUN echo "module.exports = {\n auth: 'oauth',\n token: 'GitHub personal access token'\n};" > /home/node/.config/generator-release
# You have to add a valid GitHub OAuth token!
RUN echo "module.exports = {\n auth: 'oauth',\n token: 'GitHub OAuth token'\n};" > /home/node/.config/generator-release
RUN chown -R node:node /home/node/.config
RUN chown -R node:node /home/node/.ssh
RUN chown -R node:node /home/node/tmp
# Add the generated key to GitHub: https://github.com/settings/keys
RUN ssh-keygen -q -t ed25519 -N '' -f /home/node/.ssh/id_ed25519 -C "release@handlebarsjs.com"
@@ -157,12 +155,9 @@ A full release via Docker may be completed with the following:
* Add GitHub API token: `vi /home/node/.config/generator-release`
* Execute the following steps:
```bash
npm install
npm ci
npm install -g yo@1 grunt@1 generator-release
npm run release
# Warning! This step will collect data from GitHub, bump the version,
# create a new commit, create a new tag and push it to GitHub.
# https://github.com/kpdecker/generator-release?tab=readme-ov-file#usage
yo release
npm login
npm publish
@@ -173,6 +168,7 @@ A full release via Docker may be completed with the following:
docker run --rm --interactive --tty \
--volume $PWD:/app \
--workdir /app \
--user $(id -u):$(id -g) \
ruby:3.2-slim bash
```
* Execute the following steps:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "handlebars",
"version": "4.7.9",
"version": "4.7.8",
"main": "handlebars.js",
"license": "MIT",
"dependencies": {}
+6 -2
View File
@@ -1,7 +1,7 @@
{
"name": "components/handlebars.js",
"description": "Handlebars.js and Mustache are both logicless templating languages that keep the view and the code separated like we all know they should be.",
"homepage": "https://handlebarsjs.com",
"homepage": "http://handlebarsjs.com",
"license": "MIT",
"type": "component",
"keywords": [
@@ -11,9 +11,13 @@
],
"authors": [
{
"name": "Chris Wanstrath"
"name": "Chris Wanstrath",
"homepage": "http://chriswanstrath.com"
}
],
"require": {
"robloach/component-installer": "*"
},
"extra": {
"component": {
"name": "handlebars",
+1 -1
View File
@@ -2,7 +2,7 @@
<package>
<metadata>
<id>handlebars.js</id>
<version>4.7.9</version>
<version>4.7.8</version>
<authors>handlebars.js Authors</authors>
<licenseUrl>https://github.com/handlebars-lang/handlebars.js/blob/master/LICENSE</licenseUrl>
<projectUrl>https://github.com/handlebars-lang/handlebars.js/</projectUrl>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "handlebars",
"version": "4.7.9",
"version": "4.7.8",
"license": "MIT",
"jspm": {
"main": "handlebars",
+1 -1
View File
@@ -5,7 +5,7 @@ import { registerDefaultDecorators } from './decorators';
import logger from './logger';
import { resetLoggedProperties } from './internal/proto-access';
export const VERSION = '4.7.9';
export const VERSION = '4.7.8';
export const COMPILER_REVISION = 8;
export const LAST_COMPATIBLE_COMPILER_REVISION = 7;
+21 -30
View File
@@ -1,13 +1,7 @@
/* eslint-disable new-cap */
import Exception from '../exception';
import {
isArray,
indexOf,
extend,
sanitizeDepth,
sanitizeParts
} from '../utils';
import { isArray, indexOf, extend } from '../utils';
import AST from './ast';
const slice = [].slice;
@@ -249,7 +243,7 @@ Compiler.prototype = {
name = path.parts[0],
isBlock = program != null || inverse != null;
this.opcode('getContext', sanitizeDepth(path.depth));
this.opcode('getContext', path.depth);
this.opcode('pushProgram', program);
this.opcode('pushProgram', inverse);
@@ -294,32 +288,29 @@ Compiler.prototype = {
},
PathExpression: function(path) {
// Sanitize untrusted AST values at the compiler boundary.
// javascript-compiler.js trusts all opcode arguments to be safe.
const depth = sanitizeDepth(path.depth);
const parts = sanitizeParts(path.parts);
this.addDepth(path.depth);
this.opcode('getContext', path.depth);
this.addDepth(depth);
this.opcode('getContext', depth);
let name = parts[0],
let name = path.parts[0],
scoped = AST.helpers.scopedId(path),
blockParamId = !depth && !scoped && this.blockParamIndex(name);
blockParamId = !path.depth && !scoped && this.blockParamIndex(name);
if (blockParamId) {
this.opcode(
'lookupBlockParam',
[Number(blockParamId[0]), Number(blockParamId[1])],
parts
);
this.opcode('lookupBlockParam', blockParamId, path.parts);
} else if (!name) {
// Context reference, i.e. `{{foo .}}` or `{{foo ..}}`
this.opcode('pushContext');
} else if (path.data) {
this.options.data = true;
this.opcode('lookupData', depth, parts, path.strict);
this.opcode('lookupData', path.depth, path.parts, path.strict);
} else {
this.opcode('lookupOnContext', parts, path.falsy, path.strict, scoped);
this.opcode(
'lookupOnContext',
path.parts,
path.falsy,
path.strict,
scoped
);
}
},
@@ -328,11 +319,11 @@ Compiler.prototype = {
},
NumberLiteral: function(number) {
this.opcode('pushLiteral', Number(number.value));
this.opcode('pushLiteral', number.value);
},
BooleanLiteral: function(bool) {
this.opcode('pushLiteral', bool.value === true ? 'true' : 'false');
this.opcode('pushLiteral', bool.value);
},
UndefinedLiteral: function() {
@@ -419,16 +410,16 @@ Compiler.prototype = {
pushParam: function(val) {
let value = val.value != null ? val.value : val.original || '';
let depth = sanitizeDepth(val.depth);
if (this.stringParams) {
if (value.replace) {
value = value.replace(/^(\.?\.\/)*/g, '').replace(/\//g, '.');
}
if (depth) {
this.addDepth(depth);
if (val.depth) {
this.addDepth(val.depth);
}
this.opcode('getContext', depth);
this.opcode('getContext', val.depth || 0);
this.opcode('pushStringParam', value, val.type);
if (val.type === 'SubExpression') {
+15 -28
View File
@@ -165,10 +165,12 @@ JavaScriptCompiler.prototype = {
let { programs, decorators } = this.context;
for (i = 0, l = programs.length; i < l; i++) {
ret[i] = programs[i];
if (decorators[i]) {
ret[i + '_d'] = decorators[i];
ret.useDecorators = true;
if (programs[i]) {
ret[i] = programs[i];
if (decorators[i]) {
ret[i + '_d'] = decorators[i];
ret.useDecorators = true;
}
}
}
@@ -533,22 +535,16 @@ JavaScriptCompiler.prototype = {
this.resolvePath('data', parts, 0, true, strict);
},
resolvePath: function(type, parts, startPartIndex, falsy, strict) {
resolvePath: function(type, parts, i, falsy, strict) {
if (this.options.strict || this.options.assumeObjects) {
this.push(
strictLookup(
this.options.strict && strict,
this,
parts,
startPartIndex,
type
)
strictLookup(this.options.strict && strict, this, parts, i, type)
);
return;
}
let len = parts.length;
for (let i = startPartIndex; i < len; i++) {
for (; i < len; i++) {
/* eslint-disable no-loop-func */
this.replaceStack(current => {
let lookup = this.nameLookup(current, parts[i], type);
@@ -686,18 +682,9 @@ JavaScriptCompiler.prototype = {
let foundDecorator = this.nameLookup('decorators', name, 'decorator'),
options = this.setupHelperArgs(name, paramSize);
// Store the resolved decorator in a variable and verify it is a function before
// calling it. Without this, unregistered decorators can cause an unhandled TypeError
// (calling undefined), which crashes the process — enabling Denial of Service.
this.decorators.push(['var decorator = ', foundDecorator, ';']);
this.decorators.push([
'if (typeof decorator !== "function") { throw new Error(',
this.quotedString('Missing decorator: "' + name + '"'),
'); }'
]);
this.decorators.push([
'fn = ',
this.decorators.functionCall('decorator', '', [
this.decorators.functionCall(foundDecorator, '', [
'fn',
'props',
'container',
@@ -921,8 +908,8 @@ JavaScriptCompiler.prototype = {
let existing = this.matchExistingProgram(child);
if (existing == null) {
// Placeholder to prevent name conflicts for nested children
let index = this.context.programs.push('') - 1;
this.context.programs.push(''); // Placeholder to prevent name conflicts for nested children
let index = this.context.programs.length;
child.index = index;
child.name = 'program' + index;
this.context.programs[index] = compiler.compile(
@@ -1276,14 +1263,14 @@ JavaScriptCompiler.isValidJavaScriptVariableName = function(name) {
);
};
function strictLookup(requireTerminal, compiler, parts, startPartIndex, type) {
function strictLookup(requireTerminal, compiler, parts, i, type) {
let stack = compiler.popStack(),
len = parts.length;
if (requireTerminal) {
len--;
}
for (let i = startPartIndex; i < len; i++) {
for (; i < len; i++) {
stack = compiler.nameLookup(stack, parts[i], type);
}
@@ -1293,7 +1280,7 @@ function strictLookup(requireTerminal, compiler, parts, startPartIndex, type) {
'(',
stack,
', ',
compiler.quotedString(parts[len]),
compiler.quotedString(parts[i]),
', ',
JSON.stringify(compiler.source.currentLocation),
' )'
+1 -2
View File
@@ -20,8 +20,7 @@ export function moveHelperToHooks(instance, helperName, keepHelper) {
if (instance.helpers[helperName]) {
instance.hooks[helperName] = instance.helpers[helperName];
if (!keepHelper) {
// Using delete is slow
instance.helpers[helperName] = undefined;
delete instance.helpers[helperName];
}
}
}
@@ -0,0 +1,11 @@
import { extend } from '../utils';
/**
* Create a new object with "null"-prototype to avoid truthy results on prototype properties.
* The resulting object can be used with "object[property]" to check if a property exists
* @param {...object} sources a varargs parameter of source objects that will be merged
* @returns {object}
*/
export function createNewLookupObject(...sources) {
return extend(Object.create(null), ...sources);
}
+17 -16
View File
@@ -1,31 +1,32 @@
import { extend } from '../utils';
import { createNewLookupObject } from './create-new-lookup-object';
import logger from '../logger';
const loggedProperties = Object.create(null);
export function createProtoAccessControl(runtimeOptions) {
// Create an object with "null"-prototype to avoid truthy results on
// prototype properties.
const propertyWhiteList = Object.create(null);
// eslint-disable-next-line no-proto
propertyWhiteList['__proto__'] = false;
extend(propertyWhiteList, runtimeOptions.allowedProtoProperties);
let defaultMethodWhiteList = Object.create(null);
defaultMethodWhiteList['constructor'] = false;
defaultMethodWhiteList['__defineGetter__'] = false;
defaultMethodWhiteList['__defineSetter__'] = false;
defaultMethodWhiteList['__lookupGetter__'] = false;
const methodWhiteList = Object.create(null);
methodWhiteList['constructor'] = false;
methodWhiteList['__defineGetter__'] = false;
methodWhiteList['__defineSetter__'] = false;
methodWhiteList['__lookupGetter__'] = false;
methodWhiteList['__lookupSetter__'] = false;
extend(methodWhiteList, runtimeOptions.allowedProtoMethods);
let defaultPropertyWhiteList = Object.create(null);
// eslint-disable-next-line no-proto
defaultPropertyWhiteList['__proto__'] = false;
return {
properties: {
whitelist: propertyWhiteList,
whitelist: createNewLookupObject(
defaultPropertyWhiteList,
runtimeOptions.allowedProtoProperties
),
defaultValue: runtimeOptions.allowProtoPropertiesByDefault
},
methods: {
whitelist: methodWhiteList,
whitelist: createNewLookupObject(
defaultMethodWhiteList,
runtimeOptions.allowedProtoMethods
),
defaultValue: runtimeOptions.allowProtoMethodsByDefault
}
};
+22 -24
View File
@@ -74,10 +74,17 @@ export function template(templateSpec, env) {
}
partial = env.VM.resolvePartial.call(this, partial, context, options);
options.hooks = this.hooks;
options.protoAccessControl = this.protoAccessControl;
let extendedOptions = Utils.extend({}, options, {
hooks: this.hooks,
protoAccessControl: this.protoAccessControl
});
let result = env.VM.invokePartial.call(this, partial, context, options);
let result = env.VM.invokePartial.call(
this,
partial,
context,
extendedOptions
);
if (result == null && env.compile) {
options.partials[options.name] = env.compile(
@@ -85,7 +92,7 @@ export function template(templateSpec, env) {
templateSpec.compilerOptions,
env
);
result = options.partials[options.name](context, options);
result = options.partials[options.name](context, extendedOptions);
}
if (result != null) {
if (options.indent) {
@@ -138,7 +145,7 @@ export function template(templateSpec, env) {
for (let i = 0; i < len; i++) {
let result = depths[i] && container.lookupProperty(depths[i], name);
if (result != null) {
return result;
return depths[i][name];
}
}
},
@@ -247,9 +254,8 @@ export function template(templateSpec, env) {
ret._setup = function(options) {
if (!options.partial) {
let mergedHelpers = {};
addHelpers(mergedHelpers, env.helpers, container);
addHelpers(mergedHelpers, options.helpers, container);
let mergedHelpers = Utils.extend({}, env.helpers, options.helpers);
wrapHelpersToPassLookupProperty(mergedHelpers, container);
container.helpers = mergedHelpers;
if (templateSpec.usePartial) {
@@ -349,21 +355,21 @@ export function wrapProgram(
export function resolvePartial(partial, context, options) {
if (!partial) {
if (options.name === '@partial-block') {
partial = lookupOwnProperty(options.data, 'partial-block');
partial = options.data['partial-block'];
} else {
partial = lookupOwnProperty(options.partials, options.name);
partial = options.partials[options.name];
}
} else if (!partial.call && !options.name) {
// This is a dynamic partial that returned a string
options.name = partial;
partial = lookupOwnProperty(options.partials, partial);
partial = options.partials[partial];
}
return partial;
}
export function invokePartial(partial, context, options) {
// Use the current closure context to save the partial-block if this partial
const currentPartialBlock = lookupOwnProperty(options.data, 'partial-block');
const currentPartialBlock = options.data && options.data['partial-block'];
options.partial = true;
if (options.ids) {
options.data.contextPath = options.ids[0] || options.data.contextPath;
@@ -404,12 +410,6 @@ export function noop() {
return '';
}
function lookupOwnProperty(obj, name) {
if (obj && Object.prototype.hasOwnProperty.call(obj, name)) {
return obj[name];
}
}
function initData(context, data) {
if (!data || !('root' in data)) {
data = data ? createFrame(data) : {};
@@ -435,10 +435,9 @@ function executeDecorators(fn, prog, container, depths, data, blockParams) {
return prog;
}
function addHelpers(mergedHelpers, helpers, container) {
if (!helpers) return;
Object.keys(helpers).forEach(helperName => {
let helper = helpers[helperName];
function wrapHelpersToPassLookupProperty(mergedHelpers, container) {
Object.keys(mergedHelpers).forEach(helperName => {
let helper = mergedHelpers[helperName];
mergedHelpers[helperName] = passLookupPropertyOption(helper, container);
});
}
@@ -446,7 +445,6 @@ function addHelpers(mergedHelpers, helpers, container) {
function passLookupPropertyOption(helper, container) {
const lookupProperty = container.lookupProperty;
return wrapHelper(helper, options => {
options.lookupProperty = lookupProperty;
return options;
return Utils.extend({ lookupProperty }, options);
});
}
-27
View File
@@ -114,30 +114,3 @@ export function blockParams(params, ids) {
export function appendContextPath(contextPath, id) {
return (contextPath ? contextPath + '.' : '') + id;
}
/**
* Coerce an untrusted depth value to a safe non-negative integer.
* Returns `0` for any value that is not a finite, non-negative number.
*
* @param {unknown} depth - The depth value to sanitize.
* @returns {number} A non-negative integer.
*/
export function sanitizeDepth(depth) {
let number = Number(depth);
if (!Number.isFinite(number) || number < 0) {
return 0;
}
return Math.floor(number);
}
/**
* Return a sanitized copy of a PathExpression AST node's parts array.
* Coerces each element to a string, or returns an empty array if parts
* is not an array.
*
* @param {unknown} parts - The parts value to sanitize.
* @returns {string[]} A safe string array.
*/
export function sanitizeParts(parts) {
return Array.isArray(parts) ? parts.map(String) : [];
}
+8 -45
View File
@@ -196,24 +196,16 @@ module.exports.cli = function(opts) {
const objectName = opts.partial ? 'Handlebars.partials' : 'templates';
if (opts.namespace && !isValidNamespace(opts.namespace)) {
throw new Handlebars.Exception('Invalid namespace format');
}
let output = new SourceNode();
if (!opts.simple) {
if (opts.amd) {
const runtimeModulePath =
(opts.handlebarPath || '') + 'handlebars.runtime';
output.add(
'define([' +
quoteForJavaScript(runtimeModulePath) +
'], function(Handlebars) {\n Handlebars = Handlebars["default"];'
"define(['" +
opts.handlebarPath +
'handlebars.runtime\'], function(Handlebars) {\n Handlebars = Handlebars["default"];'
);
} else if (opts.commonjs) {
output.add(
'var Handlebars = require(' + quoteForJavaScript(opts.commonjs) + ');'
);
output.add('var Handlebars = require("' + opts.commonjs + '");');
} else {
output.add('(function() {\n');
}
@@ -263,9 +255,9 @@ module.exports.cli = function(opts) {
}
output.add([
objectName,
'[',
quoteForJavaScript(template.name),
'] = template(',
"['",
template.name,
"'] = template(",
precompiled,
');\n'
]);
@@ -285,9 +277,7 @@ module.exports.cli = function(opts) {
}
if (opts.map) {
output.add(
'\n//# sourceMappingURL=' + sanitizeSourceMapComment(opts.map) + '\n'
);
output.add('\n//# sourceMappingURL=' + opts.map + '\n');
}
output = output.toStringWithSourceMap();
@@ -317,33 +307,6 @@ function arrayCast(value) {
return value;
}
/*
* Safely quotes a value for embedding in generated JavaScript strings
*
* Uses JSON.stringify which handles all special characters.
*/
function quoteForJavaScript(value) {
return JSON.stringify(String(value));
}
/**
* Validates that a namespace is a legitimate dotted JavaScript identifier
* (e.g. "App.templates") to prevent arbitrary code injection
*/
function isValidNamespace(namespace) {
return /^[A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z_$][A-Za-z0-9_$]*)*$/.test(
namespace
);
}
/**
* Strips line terminators from source map URLs to prevent injection of new
* JavaScript lines via the sourceMappingURL comment
*/
function sanitizeSourceMapComment(value) {
return String(value).replace(/[\r\n\u2028\u2029]/g, '');
}
/**
* Run uglify to minify the compiled template, if uglify exists in the dependencies.
*
+17403 -5826
View File
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -1,7 +1,7 @@
{
"name": "handlebars",
"barename": "handlebars",
"version": "4.7.9",
"version": "4.7.8",
"description": "Handlebars provides the power necessary to let you build semantic templates effectively with no frustration",
"homepage": "https://handlebarsjs.com/",
"keywords": [
@@ -30,8 +30,8 @@
"uglify-js": "^3.1.4"
},
"devDependencies": {
"@playwright/test": "1.44.1",
"aws-sdk": "^2.1.49",
"@aws-sdk/client-s3": "^3.385.0",
"@playwright/test": "^1.17.1",
"babel-loader": "^5.0.0",
"babel-runtime": "^5.1.10",
"benchmark": "~1.0",
@@ -39,6 +39,7 @@
"chai-diff": "^1.0.1",
"concurrently": "^5.0.0",
"dirty-chai": "^2.0.1",
"dtslint": "^0.5.5",
"dustjs-linkedin": "^2.0.2",
"eco": "~1.1.0-rc-3",
"eslint": "^6.7.2",
@@ -46,7 +47,7 @@
"eslint-plugin-compat": "^3.13.0",
"eslint-plugin-es5": "^1.4.1",
"fs-extra": "^8.1.0",
"grunt": "1.5.3",
"grunt": "^1.0.4",
"grunt-babel": "^5.0.0",
"grunt-cli": "^1",
"grunt-contrib-clean": "^1",
@@ -86,7 +87,7 @@
"lint": "npm run lint:eslint && npm run lint:prettier && npm run lint:types",
"lint:eslint": "eslint --max-warnings 0 .",
"lint:prettier": "prettier --check '**/*.js'",
"lint:types": "tsc --noEmit --project types",
"lint:types": "dtslint types",
"test": "npm run test:mocha",
"test:mocha": "grunt build && grunt test",
"test:browser": "playwright test --config tests/browser/playwright.config.js tests/browser/spec.js",
+1 -11
View File
@@ -2,17 +2,7 @@
## Development
[Commits](https://github.com/handlebars-lang/handlebars.js/compare/v4.7.9...master)
## v4.7.9 - March 26th, 2026
- fix: enable shell mode for spawn to resolve Windows EINVAL issue - e0137c2
- fix type "RuntimeOptions" also accepting string partials - eab1d14
- feat(types): set `hash` to be a `Record<string, any>` - de4414d
- fix non-contiguous program indices - 4512766
- refactor: rename i to startPartIndex - e497a35
- security: fix security issues - 68d8df5
[Commits](https://github.com/handlebars-lang/handlebars.js/compare/v4.7.8...v4.7.9)
[Commits](https://github.com/handlebars-lang/handlebars.js/compare/v4.7.8...master)
## v4.7.8 - July 27th, 2023
-109
View File
@@ -128,115 +128,6 @@ describe('compiler', function() {
);
});
function createPathExpressionAST(depth, parts) {
return {
type: 'Program',
body: [
{
type: 'MustacheStatement',
escaped: true,
strip: { open: false, close: false },
path: {
type: 'PathExpression',
data: false,
depth: depth,
parts: parts,
original: 'this'
},
params: []
}
]
};
}
it('should safely handle AST with non-integer PathExpression depth', function() {
// depth '0' is coerced to 0 via Number(), compiles safely
var result = Handlebars.compile(createPathExpressionAST('0', ['this']))();
expect(result).to.be.a('string');
});
it('should safely handle AST with negative PathExpression depth', function() {
// Negative depth is clamped to 0
var result = Handlebars.compile(createPathExpressionAST(-1, ['this']))();
expect(result).to.be.a('string');
});
it('should safely handle AST with fractional PathExpression depth', function() {
// Fractional depth is floored to an integer
var result = Handlebars.compile(createPathExpressionAST(0.5, ['this']))();
expect(result).to.be.a('string');
});
it('should safely handle AST with non-array PathExpression parts', function() {
// Non-array parts are coerced to empty array, compiles safely
var result = Handlebars.compile(createPathExpressionAST(0, 'this'))();
expect(result).to.be.a('string');
});
it('should safely handle AST with non-string PathExpression part', function() {
// Non-string parts are coerced to strings via String()
var result = Handlebars.compile(createPathExpressionAST(0, [1]))();
expect(result).to.be.a('string');
});
it('should safely handle AST with non-boolean BooleanLiteral value type', function() {
// The compiler coerces BooleanLiteral.value via === true before
// emitting a pushLiteral opcode, so a non-boolean value like the
// string 'true' becomes the literal 'false'.
var loc = {
source: null,
start: { line: 1, column: 0 },
end: { line: 1, column: 10 }
};
var result = Handlebars.compile({
type: 'Program',
body: [
{
type: 'MustacheStatement',
escaped: true,
strip: { open: false, close: false },
loc: loc,
path: {
type: 'BooleanLiteral',
value: 'true',
original: true,
loc: loc
},
params: []
}
]
})();
// 'true' !== true, so the compiler emits pushLiteral('false').
// Handlebars does not render falsy values, so the output is empty.
expect(result).to.equal('');
});
it('should ignore loc metadata in AST nodes', function() {
equal(
Handlebars.compile({
type: 'Program',
meta: null,
loc: { source: 'fake', start: { line: 1, column: 0 } },
body: [{ type: 'ContentStatement', value: 'Hello' }]
})(),
'Hello'
);
});
it('should accept AST with valid NumberLiteral values', function() {
equal(
Handlebars.compile(Handlebars.parse('{{lookup this 1}}'))(['a', 'b']),
'b'
);
});
it('should accept AST with valid BooleanLiteral values', function() {
equal(
Handlebars.compile(Handlebars.parse('{{#if true}}ok{{/if}}'))({}),
'ok'
);
});
it('can pass through an empty string', function() {
equal(Handlebars.compile('')(), '');
});
+2 -2
View File
@@ -1,6 +1,6 @@
define(["handlebars.runtime"], function(Handlebars) {
define(['handlebars.runtime'], function(Handlebars) {
Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {};
return templates["bom"] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return templates['bom'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return "a";
},"useData":true});
});
+2 -2
View File
@@ -1,6 +1,6 @@
define(["handlebars.runtime"], function(Handlebars) {
define(['handlebars.runtime'], function(Handlebars) {
Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {};
return templates["empty"] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return templates['empty'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return "";
},"useData":true});
});
+2 -2
View File
@@ -1,6 +1,6 @@
define(["handlebars.runtime"], function(Handlebars) {
define(['handlebars.runtime'], function(Handlebars) {
Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = CustomNamespace.templates = CustomNamespace.templates || {};
return templates["empty"] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return templates['empty'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return "";
},"useData":true});
});
+1 -1
View File
@@ -1,6 +1,6 @@
(function() {
var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {};
templates["empty"] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
templates['empty'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return "";
},"useData":true});
})();
+3 -3
View File
@@ -1,9 +1,9 @@
define(["handlebars.runtime"], function(Handlebars) {
define(['handlebars.runtime'], function(Handlebars) {
Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {};
templates["firstTemplate"] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
templates['firstTemplate'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return "<div>1</div>";
},"useData":true});
templates["secondTemplate"] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
templates['secondTemplate'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return "<div>2</div>";
},"useData":true});
return templates;
+2 -2
View File
@@ -1,6 +1,6 @@
define(["handlebars.runtime"], function(Handlebars) {
define(['handlebars.runtime'], function(Handlebars) {
Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {};
return templates["artifacts/partial.template"] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return templates['artifacts/partial.template'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return "<div>Test Partial</div>";
},"useData":true});
});
+2 -2
View File
@@ -1,6 +1,6 @@
define(["some-path/handlebars.runtime"], function(Handlebars) {
define(['some-path/handlebars.runtime'], function(Handlebars) {
Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {};
return templates["empty"] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return templates['empty'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return "";
},"useData":true});
});
+3 -3
View File
@@ -1,9 +1,9 @@
define(["handlebars.runtime"], function(Handlebars) {
define(['handlebars.runtime'], function(Handlebars) {
Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = someNameSpace = someNameSpace || {};
templates["empty"] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
templates['empty'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return "";
},"useData":true});
templates["empty"] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
templates['empty'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return "";
},"useData":true});
return templates;
+2 -2
View File
@@ -1,6 +1,6 @@
define(["handlebars.runtime"], function(Handlebars) {
define(['handlebars.runtime'], function(Handlebars) {
Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {};
return templates["non.default.extension"] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return templates['non.default.extension'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return "<div>This is a test</div>";
},"useData":true});
});
+5 -5
View File
@@ -1,6 +1,6 @@
define(["handlebars.runtime"], function(Handlebars) {
define(['handlebars.runtime'], function(Handlebars) {
Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {};
return templates["known.helpers"] = template({"0":function(container,depth0,helpers,partials,data) {
return templates['known.helpers'] = template({"1":function(container,depth0,helpers,partials,data) {
var stack1, lookupProperty = container.lookupProperty || function(parent, propertyName) {
if (Object.prototype.hasOwnProperty.call(parent, propertyName)) {
return parent[propertyName];
@@ -8,8 +8,8 @@ return parent[propertyName];
return undefined
};
return " <div>Some known helper</div>\n"
+ ((stack1 = lookupProperty(helpers,"anotherHelper").call(depth0 != null ? depth0 : (container.nullContext || {}),true,{"name":"anotherHelper","hash":{},"fn":container.program(1, data, 0),"inverse":container.noop,"data":data,"loc":{"start":{"line":3,"column":4},"end":{"line":5,"column":22}}})) != null ? stack1 : "");
},"1":function(container,depth0,helpers,partials,data) {
+ ((stack1 = lookupProperty(helpers,"anotherHelper").call(depth0 != null ? depth0 : (container.nullContext || {}),true,{"name":"anotherHelper","hash":{},"fn":container.program(2, data, 0),"inverse":container.noop,"data":data,"loc":{"start":{"line":3,"column":4},"end":{"line":5,"column":22}}})) != null ? stack1 : "");
},"2":function(container,depth0,helpers,partials,data) {
return " <div>Another known helper</div>\n";
},"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
var stack1, lookupProperty = container.lookupProperty || function(parent, propertyName) {
@@ -18,7 +18,7 @@ return parent[propertyName];
}
return undefined
};
return ((stack1 = lookupProperty(helpers,"someHelper").call(depth0 != null ? depth0 : (container.nullContext || {}),true,{"name":"someHelper","hash":{},"fn":container.program(0, data, 0),"inverse":container.noop,"data":data,"loc":{"start":{"line":1,"column":0},"end":{"line":6,"column":15}}})) != null ? stack1 : "");
return ((stack1 = lookupProperty(helpers,"someHelper").call(depth0 != null ? depth0 : (container.nullContext || {}),true,{"name":"someHelper","hash":{},"fn":container.program(1, data, 0),"inverse":container.noop,"data":data,"loc":{"start":{"line":1,"column":0},"end":{"line":6,"column":15}}})) != null ? stack1 : "");
},"useData":true});
});
+2 -2
View File
@@ -1,6 +1,6 @@
define(["handlebars.runtime"], function(Handlebars) {
define(['handlebars.runtime'], function(Handlebars) {
Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {};
return Handlebars.partials["partial.template"] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return Handlebars.partials['partial.template'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return "<div>Test Partial</div>";
},"useData":true});
});
+1 -82
View File
@@ -182,7 +182,7 @@ describe('precompiler', function() {
return 'amd';
};
Precompiler.cli({ templates: [emptyTemplate], amd: true, partial: true });
equal(/return Handlebars\.partials\["empty"\]/.test(log), true);
equal(/return Handlebars\.partials\['empty'\]/.test(log), true);
equal(/template\(amd\)/.test(log), true);
});
it('should output multiple amd partials', function() {
@@ -405,85 +405,4 @@ describe('precompiler', function() {
});
});
});
describe('GHSA-xjpj-3mr7-gcpf: precompiler output escaping', function() {
var FullHandlebars = require('../dist/cjs/handlebars')['default'];
function runCliAndCaptureOutput(options) {
var output = '';
var oldLog = console.log;
console.log = function() {
output += Array.prototype.join.call(arguments, '');
};
try {
Precompiler.cli(options);
} finally {
console.log = oldLog;
}
return output;
}
it('should not inject raw template names into generated code', function() {
var output = runCliAndCaptureOutput({
templates: [
{
name: "evil'];global.__xjpjName=1;//",
source: ''
}
],
amd: true
});
expect(output).to.not.match(/\['evil'\];global\.__xjpjName=1/);
});
it('should not inject raw commonjs option values into generated code', function() {
var output = runCliAndCaptureOutput({
templates: [{ name: 'safe', source: '' }],
commonjs: 'handlebars");global.__xjpjCommon=1;//'
});
expect(output).to.not.match(
/require\("handlebars"\);global\.__xjpjCommon=1/
);
});
it('should reject invalid namespace expressions', function() {
expect(function() {
runCliAndCaptureOutput({
templates: [{ name: 'safe', source: '' }],
namespace: 'App.ns;global.__xjpjNamespace=1;//'
});
}).to.throw(/Invalid namespace/);
});
it('should sanitize sourceMappingURL comment values', function() {
var oldPrecompile = FullHandlebars.precompile;
var oldWriteFileSync = fs.writeFileSync;
FullHandlebars.precompile = function() {
return {
code: '""',
map: '{"version":3,"sources":[],"names":[],"mappings":""}'
};
};
fs.writeFileSync = function() {};
var output;
try {
output = runCliAndCaptureOutput({
templates: [{ name: 'safe', source: '' }],
map: 'good.js.map\n;global.__xjpjMap=1;//'
});
} finally {
FullHandlebars.precompile = oldPrecompile;
fs.writeFileSync = oldWriteFileSync;
}
expect(output).to.not.match(
/sourceMappingURL=[^\n]*\n;global\.__xjpjMap=1/
);
});
});
});
+3 -10
View File
@@ -54,13 +54,6 @@ describe('runtime', function() {
/Template was precompiled with an older version of Handlebars than the current runtime/
);
});
it('should safely resolve missing partial map entries', function() {
equal(
Handlebars.VM.resolvePartial(undefined, {}, { name: 'missing' }),
undefined
);
});
});
describe('#child', function() {
@@ -98,13 +91,13 @@ describe('runtime', function() {
it('should expose child template', function() {
var template = Handlebars.compile('{{#foo}}bar{{/foo}}');
// Calling twice to hit the non-compiled case.
equal(template._child(0)(), 'bar');
equal(template._child(0)(), 'bar');
equal(template._child(1)(), 'bar');
equal(template._child(1)(), 'bar');
});
it('should render depthed content', function() {
var template = Handlebars.compile('{{#foo}}{{../bar}}{{/foo}}');
// Calling twice to hit the non-compiled case.
equal(template._child(0, undefined, [], [{ bar: 'baz' }])(), 'baz');
equal(template._child(1, undefined, [], [{ bar: 'baz' }])(), 'baz');
});
});
-220
View File
@@ -133,13 +133,11 @@ describe('security issues', function() {
'{{__defineGetter__}}',
'{{__defineSetter__}}',
'{{__lookupGetter__}}',
'{{__lookupSetter__}}',
'{{__proto__}}',
'{{lookup this "constructor"}}',
'{{lookup this "__defineGetter__"}}',
'{{lookup this "__defineSetter__"}}',
'{{lookup this "__lookupGetter__"}}',
'{{lookup this "__lookupSetter__"}}',
'{{lookup this "__proto__"}}'
];
@@ -424,224 +422,6 @@ describe('security issues', function() {
.toCompileTo('c');
});
});
describe('GHSA-2qvq-rjwj-gvw9: partial resolution must not use polluted prototypes', function() {
if (!Handlebars.compile) {
return;
}
afterEach(function() {
delete Object.prototype.widget;
});
it('should not resolve partial names from Object.prototype', function() {
// eslint-disable-next-line no-extend-native
Object.prototype.widget = '<img src=x onerror="alert(1)">';
expect(function() {
Handlebars.compile('<div>{{> widget}}</div>')({});
}).to.throw(/could not be found/);
});
});
describe('GHSA-2w6w-674q-4c4q, GHSA-xhpv-hc6g-r9c6, GHSA-3mfm-83xf-c92r: untrusted AST inputs', function() {
if (!Handlebars.compile) {
return;
}
function createInjectedProgram() {
var loc = {
source: null,
start: { line: 1, column: 0 },
end: { line: 1, column: 20 }
};
return {
type: 'Program',
body: [
{
type: 'MustacheStatement',
escaped: true,
strip: {
open: false,
close: false
},
loc: loc,
path: {
type: 'PathExpression',
data: false,
depth: 0,
parts: ['lookup'],
original: 'lookup',
loc: loc
},
params: [
{
type: 'PathExpression',
data: false,
depth: 0,
parts: [],
original: 'this',
loc: loc
},
{
type: 'NumberLiteral',
value: '{},{})) + (Function) + (({}',
original: 1,
loc: loc
}
]
}
]
};
}
it('should neutralize AST NumberLiteral type confusion in compile()', function() {
// The compiler coerces NumberLiteral.value via Number() before
// emitting a pushLiteral opcode, so a type-confused string value
// becomes NaN, preventing code injection.
var template = Handlebars.compile(createInjectedProgram());
var result = template({});
expect(result).to.not.contain('Function');
});
it('should reject AST objects passed via dynamic partial lookup', function() {
expect(function() {
var template = Handlebars.compile('{{> (lookup . "payload")}}');
template({
payload: createInjectedProgram()
});
}).to.throw(/could not be found/);
});
it('should sanitize param depth in stringParams mode', function() {
// pushParam passes val.depth directly to the getContext opcode.
// In stringParams mode, getContext stores the depth in lastContext,
// which contextName interpolates into generated code as
// 'depths[' + depth + ']'. A malicious depth string can escape the
// bracket expression and inject arbitrary code at template runtime.
//
// With sanitization the depth becomes 0, producing 'depth0' (safe).
// Without sanitization the injected expression executes and throws.
var loc = {
source: null,
start: { line: 1, column: 0 },
end: { line: 1, column: 20 }
};
var maliciousAST = {
type: 'Program',
body: [
{
type: 'MustacheStatement',
escaped: true,
strip: { open: false, close: false },
loc: loc,
path: {
type: 'PathExpression',
data: false,
depth: 0,
parts: ['lookup'],
original: 'lookup',
loc: loc
},
params: [
{
type: 'PathExpression',
data: false,
depth: 'function(){throw new Error("INJECTION")}()',
parts: [],
original: '',
loc: loc
}
]
}
]
};
var template = Handlebars.compile(maliciousAST, {
stringParams: true
});
// After sanitization the depth is 0, so the template runs without
// executing the injected throw expression.
expect(function() {
template({});
}).to.not.throw();
});
});
describe('GHSA-442j-39wm-28r2: lookup must return checked value', function() {
it('should use the validated value from lookupProperty() in compat mode', function() {
var input = { child: {} };
var readCount = 0;
Object.defineProperty(input, 'unstable', {
enumerable: true,
get: function() {
readCount++;
return readCount === 1 ? 'first-read' : 'second-read';
}
});
expectTemplate('{{#with child}}{{unstable}}{{/with}}')
.withInput(input)
.withCompileOptions({ compat: true })
.toCompileTo('first-read');
});
});
describe('GHSA-9cx6-37pm-9jff: malformed decorators should fail safely', function() {
if (!Handlebars.compile) {
return;
}
it('should throw a controlled error for unknown decorators', function() {
var template = Handlebars.compile('{{*notRegistered}}');
expect(function() {
template({});
}).to.throw(/Missing decorator|not registered/);
});
});
describe('GHSA-new: @partial-block must not resolve from polluted prototype', function() {
if (!Handlebars.compile) {
return;
}
afterEach(function() {
delete Object.prototype['partial-block'];
});
it('should not resolve @partial-block from Object.prototype', function() {
// eslint-disable-next-line no-extend-native
Object.prototype['partial-block'] = '<img src=x onerror="alert(1)">';
expect(function() {
Handlebars.compile('{{> @partial-block}}')({});
}).to.throw(/could not be found/);
});
it('should not resolve @partial-block from Object.prototype inside a partial', function() {
// eslint-disable-next-line no-extend-native
Object.prototype['partial-block'] = '<img src=x onerror="alert(1)">';
Handlebars.registerPartial('testPartial', '{{> @partial-block}}');
try {
expect(function() {
Handlebars.compile('{{> testPartial}}')({});
}).to.throw(/could not be found/);
} finally {
Handlebars.unregisterPartial('testPartial');
}
});
it('should still render legitimate @partial-block content', function() {
Handlebars.registerPartial('wrapper', '<div>{{> @partial-block}}</div>');
try {
var result = Handlebars.compile('{{#> wrapper}}hello{{/wrapper}}')({});
expect(result).to.equal('<div>hello</div>');
} finally {
Handlebars.unregisterPartial('wrapper');
}
});
});
});
function wrapToAdjustContainer(precompiledTemplateFunction) {
+127
View File
@@ -0,0 +1,127 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<style>
table {
column-gap: 1rem;
}
thead {
position: sticky;
top: 0;
background: #efefef;
}
th:not(:last-child), td:not(:last-child) {
margin-right: 1rem;
}
th {
text-align: left;
}
</style>
<title>Handlebars.js Builds</title>
</head>
<body>
<h1>Handlebars.js builds</h1>
<p>See <a href="https://handlebarsjs.com">https://handlebarsjs.com</a> for documentation.</p>
<p>Machine-readable version: <a href="{{jsonListUrl}}">{{jsonListUrl}}</a></p>
<table>
<thead>
<tr>
<th data-col="key"><a href="#" onclick="return toggleSort('key')">Name</a></th>
<th data-col="size"><a href="#" onclick="return toggleSort('size')">Size</a></th>
<th data-col="lastModified"><a href="#" onclick="return toggleSort('lastModified')">Last-Modified</a></th>
</tr>
</thead>
<tbody id="files">
{{#each fileList as | file |}}
<tr>
<td data-col="key"><a href="{{file.key}}">{{key}}</a></td>
<td data-col="size">{{file.size}}</td>
<td data-col="lastModified">{{file.lastModified}}</td>
</tr>
{{/each}}
</tbody>
</table>
<script type="application/javascript">
const files = {{{json fileList}}};
const fileElements = Array.from(document.querySelectorAll("#files > tr"));
applyNewOrder()
function getSearchParams() {
return new URLSearchParams(window.location.hash.slice(1));
}
function toggleSort(newSortProperty) {
const params = getSearchParams()
const oldSortProperty = params.get("sort");
if (oldSortProperty === newSortProperty) {
const newDir = params.get("dir") === "asc" ? "desc" : "asc"
window.location.hash = "sort=" + newSortProperty + "&dir=" + newDir
} else {
window.location.hash = "sort=" + newSortProperty
}
setTimeout(() => applyNewOrder())
return false
}
function applyNewOrder() {
const params = getSearchParams()
const sortProperty = params.get("sort") ?? "lastModified"
const ascending = params.get("dir") === "asc"
sortFilesArray(sortProperty, ascending);
updateRows();
}
function sortFilesArray(propertyName, ascending) {
files.sort(compareByProp(propertyName))
if (!ascending) {
files.reverse()
}
}
function compareByProp(propertyName) {
return (file1, file2) => {
if (file1[propertyName] === file2[propertyName]) {
return 0
}
if (file1[propertyName] > file2[propertyName]) {
return 1
}
return -1
}
}
function updateRows() {
let index = 0;
for (const rowElement of fileElements) {
update(rowElement, files[index++])
}
}
function update(rowElement, file) {
const link = rowElement.querySelector('[data-col="key"] a')
link.setAttribute("href", file.key)
link.innerText = file.key
const size = rowElement.querySelector('[data-col="size"]')
size.innerText = file.size
const lastModified = rowElement.querySelector('[data-col="lastModified"]')
lastModified.innerText = file.lastModified
}
</script>
</body>
</html>
@@ -0,0 +1,31 @@
const crypto = require('crypto');
const { runTest } = require('./test-utils/runTest');
const { createS3Client } = require('./s3client');
const { generateFileList } = require('./generateFileList');
const assert = require('node:assert');
// This is a test file. It is intended to be run manually with the proper environment variables set
//
// Run it from the project root using "node tasks/aws-s3-builds-page/generateFileList-test.js"
const s3Client = createS3Client();
runTest(async ({ log }) => {
log('Generate file list');
const filename = `test-file-list-${crypto.randomUUID()}`;
await generateFileList(filename);
log(`Checking JSON at ${s3Client.fileUrl(`${filename}.json`)}`);
const jsonList = JSON.parse(await s3Client.fetchFile(`${filename}.json`));
assert(jsonList.find(s3obj => s3obj.key === 'handlebars-v4.7.7.js'));
log(`Checking HTML at ${s3Client.fileUrl(`${filename}.html`)}`);
const htmlList = await s3Client.fetchFile(`${filename}.html`);
assert(htmlList.includes('handlebars-v4.7.7.js'));
assert(htmlList.includes('handlebarsjs.com'));
assert(!htmlList.includes('index.html'));
log(`Deleting file ${filename}.json`);
await s3Client.deleteFile(`${filename}.json`);
});
@@ -0,0 +1,39 @@
/* eslint-disable no-console */
const { createS3Client } = require('./s3client');
const Handlebars = require('../..');
const fs = require('node:fs/promises');
const path = require('path');
async function generateFileList(nameWithoutExtension) {
const s3Client = createS3Client();
const fileList = await s3Client.listFiles();
const relevantFiles = fileList.filter(s3obj => s3obj.key.endsWith('.js'));
await uploadJson(s3Client, relevantFiles, nameWithoutExtension);
await uploadHtml(s3Client, relevantFiles, nameWithoutExtension);
}
async function uploadJson(s3Client, fileList, nameWithoutExtension) {
const fileListJson = JSON.stringify(fileList, null, 2);
await s3Client.uploadData(fileListJson, nameWithoutExtension + '.json', {
contentType: 'application/json'
});
}
async function uploadHtml(s3Client, fileList, nameWithoutExtension) {
const templateStr = await fs.readFile(
path.join(__dirname, 'fileList.hbs'),
'utf-8'
);
const template = Handlebars.compile(templateStr);
Handlebars.registerHelper('json', obj => JSON.stringify(obj));
const fileListHtml = template({
fileList,
jsonListUrl: nameWithoutExtension + '.json'
});
await s3Client.uploadData(fileListHtml, nameWithoutExtension + '.html', {
contentType: 'text/html'
});
}
module.exports = { generateFileList };
+47
View File
@@ -0,0 +1,47 @@
const crypto = require('crypto');
const { publishWithSuffixes } = require('./publish');
const { runTest } = require('./test-utils/runTest');
const { createS3Client } = require('./s3client');
const fs = require('node:fs/promises');
// This is a test file. It is intended to be run manually with the proper environment variables set
//
// Run it from the project root using "node tasks/aws-s3-builds-page/publish-test.js"
const s3Client = createS3Client();
runTest(async ({ log }) => {
const suffix1 = `-test-file-` + crypto.randomUUID();
const suffix2 = `-test-file-` + crypto.randomUUID();
log(`Publish ${suffix1} and ${suffix2}`);
await publishWithSuffixes([suffix1, suffix2]);
await compareAndDeleteFiles(suffix1, log);
await compareAndDeleteFiles(suffix2, log);
});
async function compareAndDeleteFiles(suffix, log) {
const pairs = [
['dist/handlebars.js', `handlebars${suffix}.js`],
['dist/handlebars.min.js', `handlebars.min${suffix}.js`],
['dist/handlebars.runtime.js', `handlebars.runtime${suffix}.js`],
['dist/handlebars.runtime.min.js', `handlebars.runtime.min${suffix}.js`]
];
for (const [localFile, remoteFile] of pairs) {
await expectSameContents(localFile, remoteFile, log);
log(`Deleting "${remoteFile}"`);
await s3Client.deleteFile(remoteFile);
}
}
async function expectSameContents(localFile, remoteFile, log) {
log(
`Checking file contents "${localFile}" vs "${s3Client.fileUrl(remoteFile)}"`
);
const remoteContents = await s3Client.fetchFile(remoteFile);
const localContents = await fs.readFile(localFile, 'utf-8');
if (remoteContents !== localContents) {
throw new Error(
`Files do not match: ${localFile}" vs "${s3Client.fileUrl(remoteFile)}"`
);
}
}
+37
View File
@@ -0,0 +1,37 @@
/* eslint-disable no-console */
const { createS3Client } = require('./s3client');
const filenames = [
'handlebars.js',
'handlebars.min.js',
'handlebars.runtime.js',
'handlebars.runtime.min.js'
];
async function publishWithSuffixes(suffixes) {
const s3Client = createS3Client();
const publishPromises = suffixes.map(suffix =>
publishSuffix(s3Client, suffix)
);
return Promise.all(publishPromises);
}
async function publishSuffix(s3client, suffix) {
const publishPromises = filenames.map(async filename => {
const nameInBucket = getNameInBucket(filename, suffix);
const localFile = getLocalFile(filename);
await s3client.uploadFile(localFile, nameInBucket);
console.log(`Published ${localFile} to build server (${nameInBucket})`);
});
return Promise.all(publishPromises);
}
function getNameInBucket(filename, suffix) {
return filename.replace(/\.js$/, suffix + '.js');
}
function getLocalFile(filename) {
return 'dist/' + filename;
}
module.exports = { publishWithSuffixes };
@@ -0,0 +1,11 @@
const { DeleteObjectCommand } = require('@aws-sdk/client-s3');
async function deleteFile(s3Client, bucket, remoteName) {
const command = new DeleteObjectCommand({
Bucket: bucket,
Key: remoteName
});
await s3Client.send(command);
}
module.exports = { deleteFile };
@@ -0,0 +1,10 @@
async function fetchFile(bucket, remoteName) {
return (await fetch(fileUrl(bucket, remoteName))).text();
}
function fileUrl(bucket, remoteName) {
const bucketUrl = `https://s3.amazonaws.com/${bucket}`;
return `${bucketUrl}/${remoteName}`;
}
module.exports = { fetchFile, fileUrl };
@@ -0,0 +1,42 @@
const { listFiles } = require('./listFiles');
const { uploadFile, uploadData } = require('./uploadFile');
const { deleteFile } = require('./deleteFile');
const { S3Client } = require('@aws-sdk/client-s3');
const { requireEnvVar } = require('./requireEnvVar');
const { fetchFile, fileUrl } = require('./fetchFile');
module.exports = { createS3Client };
function createS3Client() {
// https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-environment.html
requireEnvVar('AWS_ACCESS_KEY_ID');
requireEnvVar('AWS_SECRET_ACCESS_KEY');
const bucket = requireEnvVar('S3_BUCKET_NAME');
const s3Client = new S3Client({
region: 'us-east-1'
});
return {
async listFiles() {
return listFiles(s3Client, bucket);
},
async uploadFile(localName, remoteName, { contentType } = {}) {
await uploadFile(s3Client, bucket, localName, remoteName, {
contentType
});
},
async uploadData(data, remoteName, { contentType } = {}) {
await uploadData(s3Client, bucket, data, remoteName, { contentType });
},
async deleteFile(remoteName) {
await deleteFile(s3Client, bucket, remoteName);
},
async fetchFile(remoteName) {
return fetchFile(bucket, remoteName);
},
fileUrl(remoteName) {
return fileUrl(bucket, remoteName);
}
};
}
@@ -0,0 +1,32 @@
const { ListObjectsV2Command } = require('@aws-sdk/client-s3');
async function listFiles(s3Client, bucket) {
const command = new ListObjectsV2Command({
Bucket: bucket
});
let isTruncated = true;
const files = [];
while (isTruncated) {
const {
Contents,
IsTruncated,
NextContinuationToken
} = await s3Client.send(command);
files.push(...Contents.map(dataFromS3Object));
isTruncated = IsTruncated;
command.input.ContinuationToken = NextContinuationToken;
}
return files;
}
function dataFromS3Object(s3obj) {
return {
key: s3obj.Key,
size: s3obj.Size,
lastModified: s3obj.LastModified.toISOString()
};
}
module.exports = { listFiles };
@@ -0,0 +1,8 @@
function requireEnvVar(name) {
if (!process.env[name]) {
throw new Error(`Environment variable "${name}" is required.`);
}
return process.env[name];
}
module.exports = { requireEnvVar };
@@ -0,0 +1,89 @@
/* eslint-disable no-console */
const { createS3Client } = require('./index');
const crypto = require('crypto');
const { runTest } = require('../test-utils/runTest');
const assert = require('node:assert');
// This is a test file. It is intended to be run manually
// with the proper environment variables set
// It tests whether the upload/list/delete methods in this directory
// work properly.
//
// Run it from the project root using "node tasks/aws-s3-builds-page/s3client/s3-test.js"
const client = createS3Client();
const ISO_DATE = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/;
runTest(async ({ log }) => {
const uuid = crypto.randomUUID();
const filename = `test-file-${uuid}`;
log(`Starting test with target file "${filename}"`);
log(`Uploading "${filename}"`);
await client.uploadFile('package.json', filename);
log(`Check if uploaded "${filename}"`);
const listing = await client.listFiles();
if (!listing.find(s3obj => s3obj.key === filename)) {
throw new Error(`File "${filename}" has not been uploaded`);
}
log(`Check contents of "${filename}"`);
const uploadedContents = await client.fetchFile(filename);
expectStringContains('"name": "handlebars"', uploadedContents);
await expectContentType(filename, 'application/octet-stream');
log(`Uploading with content type "${filename}"`);
await client.uploadFile('package.json', filename, {
contentType: 'text/html'
});
log('Checking content-type');
await expectContentType(filename, 'text/html');
log('Upload data as text/plain');
await client.uploadData('Hello world', filename, {
contentType: 'text/plain'
});
log('Checking content-type');
await expectContentType(filename, 'text/plain');
log(`Check contents of "${filename}"`);
expectStringContains('Hello world', await client.fetchFile(filename));
const helloWorldObj = (await client.listFiles()).find(
s3obj => s3obj.key === filename
);
assert.equal(helloWorldObj.size, 11, 'Checking file size of hello world');
assert.match(
helloWorldObj.lastModified,
ISO_DATE,
'Last modified must be an iso-date'
);
log(`Delete "${filename}"`);
await client.deleteFile(filename);
log(`Check if deleted "${filename}"`);
const foundFile = (await client.listFiles()).find(
s3obj => s3obj.key === filename
);
if (foundFile != null) {
throw new Error(`File "${filename}" has not been deleted`);
}
});
function expectStringContains(needle, haystack) {
if (!haystack.includes(needle)) {
throw new Error(`Expecting to find "${needle}" in string "${haystack}"`);
}
}
async function expectContentType(remoteName, expectedContentType) {
const contentType = (await fetch(client.fileUrl(remoteName))).headers.get(
'Content-Type'
);
if (contentType !== expectedContentType) {
throw new Error(
`Expecting to find content-type "${expectedContentType}" but found "${contentType}"`
);
}
}
@@ -0,0 +1,31 @@
const { PutObjectCommand } = require('@aws-sdk/client-s3');
const fs = require('node:fs/promises');
async function uploadFile(
s3Client,
bucket,
localName,
remoteName,
{ contentType } = {}
) {
const fileContents = await fs.readFile(localName);
await uploadData(s3Client, bucket, fileContents, remoteName, { contentType });
}
async function uploadData(
s3Client,
bucket,
data,
remoteName,
{ contentType } = {}
) {
const command = new PutObjectCommand({
Bucket: bucket,
Key: remoteName,
Body: data,
ContentType: contentType
});
await s3Client.send(command);
}
module.exports = { uploadFile, uploadData };
@@ -0,0 +1,37 @@
/* eslint-disable no-console */
const { createS3Client } = require('../s3client/index');
const s3Client = createS3Client();
function runTest(asyncFn) {
asyncFn({ log: console.log.bind(console) })
.finally(detectSurplusFiles)
.then(() => {
console.log('DONE');
})
.catch(error => {
console.error(error);
process.exit(1);
});
}
async function detectSurplusFiles() {
const listing = await s3Client.listFiles();
let surplusFileDetected = false;
const testFilesInBucket = listing.filter(name =>
name.key.includes('test-file')
);
for (const { key: filename } of testFilesInBucket) {
if (process.argv[2] === '--delete-surplus') {
await s3Client.deleteFile(filename);
} else {
console.log(`Detected surplus file "${filename}"`);
surplusFileDetected = true;
}
}
if (surplusFileDetected) {
console.log(`run with --delete-surplus to delete surplus files`);
}
}
module.exports = { runTest };
+4 -71
View File
@@ -1,7 +1,8 @@
const AWS = require('aws-sdk');
const git = require('./util/git');
const { createRegisterAsyncTaskFn } = require('./util/async-grunt-task');
const semver = require('semver');
const { publishWithSuffixes } = require('./aws-s3-builds-page/publish');
const { generateFileList } = require('./aws-s3-builds-page/generateFileList');
module.exports = function(grunt) {
const registerAsyncTask = createRegisterAsyncTaskFn(grunt);
@@ -27,76 +28,8 @@ module.exports = function(grunt) {
}
if (suffixes.length > 0) {
initSDK();
grunt.log.writeln(
'publishing file-suffixes: ' + JSON.stringify(suffixes)
);
await publish(suffixes);
await publishWithSuffixes(suffixes);
await generateFileList('index');
}
});
function initSDK() {
const bucket = process.env.S3_BUCKET_NAME,
key = process.env.S3_ACCESS_KEY_ID,
secret = process.env.S3_SECRET_ACCESS_KEY;
if (!bucket || !key || !secret) {
throw new Error('Missing S3 config values');
}
AWS.config.update({ accessKeyId: key, secretAccessKey: secret });
}
async function publish(suffixes) {
const publishPromises = suffixes.map(suffix => publishSuffix(suffix));
return Promise.all(publishPromises);
}
async function publishSuffix(suffix) {
const filenames = [
'handlebars.js',
'handlebars.min.js',
'handlebars.runtime.js',
'handlebars.runtime.min.js'
];
const publishPromises = filenames.map(async filename => {
const nameInBucket = getNameInBucket(filename, suffix);
const localFile = getLocalFile(filename);
await uploadToBucket(localFile, nameInBucket);
grunt.log.writeln(
`Published ${localFile} to build server (${nameInBucket})`
);
});
return Promise.all(publishPromises);
}
async function uploadToBucket(localFile, nameInBucket) {
const bucket = process.env.S3_BUCKET_NAME;
const uploadParams = {
Bucket: bucket,
Key: nameInBucket,
Body: grunt.file.read(localFile)
};
return s3PutObject(uploadParams);
}
};
function s3PutObject(uploadParams) {
const s3 = new AWS.S3();
return new Promise((resolve, reject) => {
s3.putObject(uploadParams, err => {
if (err != null) {
return reject(err);
}
resolve();
});
});
}
function getNameInBucket(filename, suffix) {
return filename.replace(/\.js$/, suffix + '.js');
}
function getLocalFile(filename) {
return 'dist/' + filename;
}
+1 -2
View File
@@ -23,8 +23,7 @@ async function execFileWithInheritedOutput(command, args) {
return new Promise((resolve, reject) => {
const resolvedCommand = preferLocalDependencies(command);
const child = childProcess.spawn(resolvedCommand, args, {
stdio: 'inherit',
shell: process.platform === 'win32' // Workaround for CVE-2024-27980
stdio: 'inherit'
});
child.on('exit', code => {
if (code !== 0) {
+2 -1
View File
@@ -9,5 +9,6 @@ Execute the following commands in the project root:
```bash
npm install
npx grunt prepare
docker run -it --rm --volume $(pwd):/srv/app --workdir /srv/app --ipc=host mcr.microsoft.com/playwright:v1.44.1-jammy npm run test:browser
docker pull mcr.microsoft.com/playwright:focal
docker run -it --rm --volume $(pwd):/srv/app --workdir /srv/app --ipc=host mcr.microsoft.com/playwright:focal npm run test:browser
```
-1
View File
@@ -2,7 +2,6 @@ const { devices } = require('@playwright/test');
/** @type {import('@playwright/test').PlaywrightTestConfig} */
const config = {
testMatch: ['spec.js'],
projects: [
{
name: 'chromium',
+10 -10
View File
@@ -7,27 +7,27 @@ async function waitForMochaAndAssertResult(page) {
expect(mochaResults.failures).toBe(0);
}
test('Spec handlebars.js', async ({ page }) => {
await page.goto(`/spec/?headless=true`);
test('Spec handlebars.js', async ({ page, baseURL }) => {
await page.goto(`${baseURL}/spec/?headless=true`);
await waitForMochaAndAssertResult(page);
});
test('Spec handlebars.amd.js (AMD)', async ({ page }) => {
await page.goto(`/spec/amd.html?headless=true`);
test('Spec handlebars.amd.js (AMD)', async ({ page, baseURL }) => {
await page.goto(`${baseURL}/spec/amd.html?headless=true`);
await waitForMochaAndAssertResult(page);
});
test('Spec handlebars.runtime.amd.js (AMD)', async ({ page }) => {
await page.goto(`/spec/amd-runtime.html?headless=true`);
test('Spec handlebars.runtime.amd.js (AMD)', async ({ page, baseURL }) => {
await page.goto(`${baseURL}/spec/amd-runtime.html?headless=true`);
await waitForMochaAndAssertResult(page);
});
test('Spec handlebars.js (UMD)', async ({ page }) => {
await page.goto(`/spec/umd.html?headless=true`);
test('Spec handlebars.js (UMD)', async ({ page, baseURL }) => {
await page.goto(`${baseURL}/spec/umd.html?headless=true`);
await waitForMochaAndAssertResult(page);
});
test('Spec handlebars.runtime.js (UMD)', async ({ page }) => {
await page.goto(`/spec/umd-runtime.html?headless=true`);
test('Spec handlebars.runtime.js (UMD)', async ({ page, baseURL }) => {
await page.goto(`${baseURL}/spec/umd-runtime.html?headless=true`);
await waitForMochaAndAssertResult(page);
});
+3 -3
View File
@@ -25,7 +25,7 @@ declare namespace Handlebars {
partial?: boolean;
depths?: any[];
helpers?: { [name: string]: Function };
partials?: { [name: string]: Template };
partials?: { [name: string]: HandlebarsTemplateDelegate };
decorators?: { [name: string]: Function };
data?: any;
blockParams?: any[];
@@ -39,7 +39,7 @@ declare namespace Handlebars {
export interface HelperOptions {
fn: TemplateDelegate;
inverse: TemplateDelegate;
hash: Record<string, any>;
hash: any;
data?: any;
}
@@ -60,7 +60,7 @@ declare namespace Handlebars {
export function unregisterHelper(name: string): void;
export function registerPartial(name: string, fn: Template): void;
export function registerPartial(spec: { [name: string]: Template }): void;
export function registerPartial(spec: { [name: string]: HandlebarsTemplateDelegate }): void;
export function unregisterPartial(name: string): void;
// TODO: replace Function with actual signature
-3
View File
@@ -249,9 +249,6 @@ function testProtoAccessControlControlOptions() {
allowedProtoProperties: { allowedProperty: true, forbiddenProperty: false },
allowProtoMethodsByDefault: true,
allowProtoPropertiesByDefault: false,
partials: {
link: '<a href="/people/{{id}}">{{name}}</a>'
}
}
);
}