Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test(HTML): Apply whitelist to HTML in HTML accepting properties #4259

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion css/properties/background-blend-mode.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
},
"edge": {
"version_added": false,
"notes": "EdgeHTML 18 has an <i>Enable CSS background-blend-mode property</i> flag, however the feature is an early prototype with no discernable end-user effect."
"notes": "EdgeHTML 18 has an <em>Enable CSS background-blend-mode property</em> flag, however the feature is an early prototype with no discernable end-user effect."
},
"firefox": {
"version_added": "30"
Expand Down
4 changes: 2 additions & 2 deletions html/global_attributes.json
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,7 @@
"value_to_set": "ContextMenu"
}
],
"notes": "This was removed from the <i>Enable Experimental Web Platform Features</i> due to a <a href='https://crbug.com/412945'>Web compatibility issue</a>. In June 2017, it was removed entirely from the browsers. This is documented in <a href='https://crbug.com/87553'>Chromium bug 87553</a>."
"notes": "This was removed from the <em>Enable Experimental Web Platform Features</em> preference due to a <a href='https://crbug.com/412945'>Web compatibility issue</a>. In June 2017, it was removed entirely from the browsers. This is documented in <a href='https://crbug.com/87553'>Chromium bug 87553</a>."
},
{
"version_added": true,
Expand Down Expand Up @@ -575,7 +575,7 @@
"value_to_set": "ContextMenu"
}
],
"notes": "This was removed from the <i>Enable Experimental Web Platform Features</i> due to a <a href='https://crbug.com/412945'>Web compatibility issue</a>. In June 2017, it was removed entirely from the browsers. This is documented in <a href='https://crbug.com/87553'>Chromium bug 87553</a>."
"notes": "This was removed from the <em>Enable Experimental Web Platform Features</em> preference due to a <a href='https://crbug.com/412945'>Web compatibility issue</a>. In June 2017, it was removed entirely from the browsers. This is documented in <a href='https://crbug.com/87553'>Chromium bug 87553</a>."
},
{
"version_added": true,
Expand Down
7 changes: 6 additions & 1 deletion test/lint.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const yargs = require('yargs');
const chalk = require('chalk');
const {
testBrowsers,
testHTML,
testPrefix,
testRealValues,
testStyle,
Expand Down Expand Up @@ -52,7 +53,8 @@ function load(...files) {
hasBrowserErrors = false,
hasVersionErrors = false,
hasRealValueErrors = false,
hasPrefixErrors = false;
hasPrefixErrors = false,
hasHTMLErrors = false;
const relativeFilePath = path.relative(process.cwd(), file);

const spinner = ora({
Expand Down Expand Up @@ -85,6 +87,7 @@ function load(...files) {
hasVersionErrors = testVersions(file);
hasRealValueErrors = testRealValues(file);
hasPrefixErrors = testPrefix(file);
hasHTMLErrors = testHTML(file);
}
} catch (e) {
hasSyntaxErrors = true;
Expand All @@ -99,6 +102,7 @@ function load(...files) {
hasVersionErrors,
hasRealValueErrors,
hasPrefixErrors,
hasHTMLErrors,
].some(x => !!x);

if (fileHasErrors) {
Expand Down Expand Up @@ -153,6 +157,7 @@ if (hasErrors) {
testRealValues(file);
testBrowsers(file);
testPrefix(file);
testHTML(file);
}
} catch (e) {
console.error(e);
Expand Down
2 changes: 2 additions & 0 deletions test/linter/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use strict';
const testBrowsers = require('./test-browsers.js');
const testHTML = require('./test-html.js');
const testPrefix = require('./test-prefix.js');
const testRealValues = require('./test-real-values.js');
const testSchema = require('./test-schema.js');
Expand All @@ -8,6 +9,7 @@ const testVersions = require('./test-versions.js');

module.exports = {
testBrowsers,
testHTML,
testPrefix,
testRealValues,
testStyle,
Expand Down
244 changes: 244 additions & 0 deletions test/linter/test-html.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
'use strict';
const fs = require('fs');
const chalk = require('chalk');

/**
* @typedef {import('../../types').Identifier} Identifier
* @typedef {import('../../types').SimpleSupportStatement} SimpleSupportStatement
* @typedef {import('../../types').CompatStatement} CompatStatement
*
* @typedef {import('../utils').Logger} Logger
* @typedef {{name:string,nameStart:number,nameEnd:number,value?:string,valueEnd?:number}} AttributeDescriptor
*/

/** A regular expression used to match HTML elements. */
const ELEMENT_REGEXP = String.raw`<([a-zA-Z][^\s/>]*)(?: (.*?))?>(.*?)</\1\s*>`;
/** A regular expression used to match HTML attributes. */
const ATTR_REGEXP = String.raw`([^\x00-\x20\x7F-\x9F"'>/=\uFDD0-\uFDEF]+)(?: *= *('[^']*'|\\"[^"]*\\"|[^\x09\x0A\x0C\x0D\x20"'=<>\x60]+))?`;

/** Elements that are allowed in all properties. */
const ALLOWED_GLOBAL_ELEMENTS = [
// Force newline
'code',
'em',
'kbd',
'strong',
];

/**
* Elements allowed only in specific properties.
*
* @type {{[property: string]: string[]}}
*/
const ALLOWED_PROPERTY_ELEMENTS = {
notes: ['a'],
};

/**
* Special attributes that are limited only to specific elements.
*
* @type {{[element: string]: string[]}}
*/
const ALLOWED_ELEMENT_ATTRIBUTES = {
a: ['href'],
};

/**
* Checks all properties that may contain HTML in the `CompatStatement`.
*
* @param {CompatStatement} compat The browser compatibility statement.
* @param {string} path The path of the feature.
* @param {Logger} logger The logger.
*/
function checkCompatStatement(compat, path, logger) {
let hasErrors = false;
if (compat.description) {
lintHTMLString(
compat.description,
'description',
path,
logger,
// Allow `<a>` when `mdn_url` is not specified:
compat.mdn_url ? undefined : ['a'],
);
}

for (const browser in compat.support) {
/** @type {SimpleSupportStatement[]} */
const supportStatements = [];
if (Array.isArray(compat.support[browser])) {
Array.prototype.push.apply(supportStatements, compat.support[browser]);
} else {
supportStatements.push(/** @type {any} */ (compat.support[browser]));
}

for (const statement of supportStatements) {
const notes = Array.isArray(statement.notes)
? statement.notes
: [statement.notes];

for (const note of notes) {
lintHTMLString(note, 'notes', `${path} (${browser})`, logger);
}
}
}
}

/**
*
* @param {string} html The HTML string to lint
* @param {string} property The property used as an index to `ALLOWED_PROPERTY_ELEMENTS`.
* @param {string} path The path of the feature.
* @param {Logger} logger The logger.
* @param {string[]} [specialElements] Dynamically allowed elements
*/
function lintHTMLString(html, property, path, logger, specialElements) {
const regexp = new RegExp(ELEMENT_REGEXP, 'gu');
const allowedElements = [...ALLOWED_GLOBAL_ELEMENTS];

if (specialElements) {
Array.prototype.push.apply(allowedElements, specialElements);
}

if (ALLOWED_PROPERTY_ELEMENTS[property]) {
Array.prototype.push.apply(
allowedElements,
ALLOWED_PROPERTY_ELEMENTS[property],
);
}

/** @type {RegExpExecArray | null} */ let match;
/** @type {RegExpExecArray | null} */ let attrMatch;

while (!!(match = regexp.exec(html))) {
const attrRegexp = new RegExp(ATTR_REGEXP, 'gu');
const [, actualElementName, attributesActual] = match;

const realElementName = actualElementName.toLowerCase();

if (!allowedElements.includes(realElementName)) {
logger.error(
chalk`{red {bold ${path}} - Element <${realElementName}> is not allowed in property '${property}'.}`,
);
continue;
}

if (actualElementName !== realElementName) {
logger.error(
chalk`{red {bold ${path}} - Use lowercase element name ({yellow <${actualElementName}>} → {green <${realElementName}>}).}`,
);
}

/** @type {string[]} */
const allowedAttributes = [];
if (ALLOWED_ELEMENT_ATTRIBUTES[realElementName]) {
Array.prototype.push.apply(
allowedAttributes,
ALLOWED_ELEMENT_ATTRIBUTES[realElementName],
);
}

/** @type {AttributeDescriptor[]} */
const badAttributes = [];

if (attributesActual) {
while (!!(attrMatch = attrRegexp.exec(attributesActual))) {
const [attrFull, attrName, attrValue] = attrMatch;
const attrStart = attrMatch.index;

/** @type {AttributeDescriptor} */
let attrDescriptor = {
name: attrName,
nameStart: attrStart,
nameEnd: attrStart + attrName.length,
};

if (attrValue != null) {
attrDescriptor.value = attrValue;
attrDescriptor.valueEnd = attrStart + attrFull.length;
}

if (!allowedAttributes.includes(attrName)) {
badAttributes.push(attrDescriptor);
}
}
}

if (badAttributes.length > 0) {
const badAttributesString = badAttributes
.reduce(
(badAttrs, { name, value }) => {
let result = name;
if (typeof value === 'string') {
result += '=' + value.includes("'") ? `"${value}"` : `'${value}'`;
}
badAttrs.push(result);
return badAttrs;
},
/** @type {string[]} */ ([]),
)
.join(', ');

logger.error(
chalk`{red {bold ${path}} - Element <${realElementName}> has disallowed attributes: ${badAttributesString}}`,
);
if (allowedAttributes.length > 0) {
logger.error(
chalk`{red {bold ${path}} - Valid attributes for <${realElementName}> are: ${allowedAttributes.join(
', ',
)}}`,
);
}
}
}
}

/**
* @param {string} filename
*/
function testHTML(filename) {
/** @type {Identifier} */
const data = require(filename);

/** @type {string[]} */
const errors = [];
const logger = {
/** @param {...unknown} message */
error: (...message) => {
errors.push(message.join(' '));
},
};

/**
* @param {Identifier} data
* @param {string} [path]
*/
function walkTree(data, path) {
for (const prop in data) {
if (prop === '__compat') {
checkCompatStatement(data[prop], path, logger);
continue;
}
const sub = data[prop];
if (typeof sub === 'object') {
walkTree(sub, path ? `${path}.${prop}` : `${prop}`);
}
}
}
walkTree(data);

if (errors.length > 0) {
console.error(
chalk`{red HTML – {bold ${errors.length}} ${
errors.length === 1 ? 'error' : 'errors'
}:}`,
);
for (const error of errors) {
console.error(` ${error}`);
}
return true;
}
return false;
}

module.exports = testHTML;