267 lines
No EOL
7 KiB
JavaScript
267 lines
No EOL
7 KiB
JavaScript
module.exports = function(options) {
|
|
const relevantTags = Array.from(options.elements);
|
|
|
|
if(relevantTags.includes('form')) {
|
|
relevantTags.push('button');
|
|
}
|
|
|
|
// AST = Abstract Syntax Tree from HTML Parser
|
|
return function(AST) {
|
|
// Plugin is 'turned off'
|
|
if(!options.noreferrer && !options.noopener) {
|
|
return AST;
|
|
}
|
|
|
|
// List of relevant elements to be iterated over
|
|
const elements = {
|
|
a: [],
|
|
area: [],
|
|
form: [],
|
|
button: []
|
|
};
|
|
|
|
AST.walk(node => {
|
|
const tag = node.tag && node.tag.toLowerCase();
|
|
|
|
if(tag && relevantTags.includes(tag)) {
|
|
elements[tag].push(node);
|
|
}
|
|
|
|
return node;
|
|
});
|
|
|
|
makeLinksSafe(elements, options);
|
|
makeFormsSafe(elements, options, AST);
|
|
|
|
return AST;
|
|
};
|
|
}
|
|
|
|
|
|
function makeLinksSafe(elements, options) {
|
|
elements['a'].concat(elements['area']).forEach(node => {
|
|
if(node.attrs) {
|
|
const state = {
|
|
done: false,
|
|
hasNoopener: false,
|
|
hasUnsafeLink: false,
|
|
hasUnsafeTarget: false
|
|
}
|
|
const rel = node.attrs[getAttributeKey('rel', node.attrs)];
|
|
|
|
if(hasWord('(?:noreferrer|opener)', rel)) {
|
|
// No need to add a rel attribute
|
|
return;
|
|
}
|
|
else if(hasWord('noopener', rel)) {
|
|
state.hasNoopener = true;
|
|
}
|
|
|
|
const link = node.attrs[getAttributeKey('href', node.attrs)];
|
|
const target = node.attrs[getAttributeKey('target', node.attrs)];
|
|
|
|
updateUnsafeStatus(link, target, state, node, options);
|
|
}
|
|
})
|
|
}
|
|
|
|
function makeFormsSafe(elements, options, tree) {
|
|
elements['form'].forEach(form => {
|
|
const state = {
|
|
done: false,
|
|
hasNoopener: false,
|
|
hasUnsafeLink: false,
|
|
hasUnsafeTarget: false
|
|
}
|
|
|
|
// Action attribute contains the URL on forms
|
|
if(form.attrs) {
|
|
const rel = form.attrs[getAttributeKey('rel', form.attrs)];
|
|
|
|
if(hasWord('(?:noreferrer|opener)', rel)) {
|
|
// No need to add a rel attribute
|
|
return;
|
|
}
|
|
else if(hasWord('noopener', rel)) {
|
|
state.hasNoopener = true;
|
|
}
|
|
|
|
const link = form.attrs[getAttributeKey('action', form.attrs)];
|
|
const target = form.attrs[getAttributeKey('target', form.attrs)];
|
|
|
|
updateUnsafeStatus(link, target, state, form, options);
|
|
}
|
|
|
|
if(state.done) {
|
|
return;
|
|
}
|
|
|
|
// Check for nested buttons or inputs in the form with an overriding formaction/formtarget attribute
|
|
tree.walk.call(form, formChild => {
|
|
if(state.done || !formChild.tag || !formChild.attrs) {
|
|
return formChild;
|
|
}
|
|
|
|
const tag = formChild.tag.toLowerCase();
|
|
const type = formChild.attrs[getAttributeKey('type', formChild.attrs)];
|
|
const hasTypeButton = type && type.trim().toLowerCase() === 'button';
|
|
const hasTypeReset = type && type.trim().toLowerCase() === 'reset';
|
|
const hasTypeImage = type && type.trim().toLowerCase() === 'image';
|
|
const hasTypeSubmit = type && type.trim().toLowerCase() === 'submit';
|
|
const isRelevantButton = tag === 'button' && !hasTypeButton && !hasTypeReset;
|
|
const isRelevantInput = tag === 'input' && (hasTypeImage || hasTypeSubmit);
|
|
|
|
if(isRelevantButton || isRelevantInput) {
|
|
if(!state.hasUnsafeLink) {
|
|
const link = formChild.attrs[getAttributeKey('formaction', formChild.attrs)];
|
|
updateUnsafeStatus(link, null, state, form, options);
|
|
}
|
|
|
|
if(!state.hasUnsafeTarget) {
|
|
const target = formChild.attrs[getAttributeKey('formtarget', formChild.attrs)];
|
|
updateUnsafeStatus(null, target, state, form, options);
|
|
}
|
|
}
|
|
|
|
return formChild;
|
|
})
|
|
|
|
if(state.done) {
|
|
return;
|
|
}
|
|
|
|
// If form has an ID, check for buttons anywhere in the document with an overriding formaction/formtarget attribute
|
|
const formID = form.attrs && form.attrs[getAttributeKey('id', form.attrs)];
|
|
|
|
if(!formID) {
|
|
return;
|
|
}
|
|
|
|
for(let button of elements['button']) {
|
|
if(!button.attrs) {
|
|
continue;
|
|
}
|
|
|
|
const formAttribute = button.attrs[getAttributeKey('form', button.attrs)];
|
|
const hasReleventFormAttribute = formAttribute && formAttribute.trim() === formID;
|
|
const type = button.attrs[getAttributeKey('type', button.attrs)];
|
|
const hasButtonType = type && type.trim().toLowerCase() === 'button';
|
|
const hasResetType = type && type.trim().toLowerCase() === 'reset';
|
|
|
|
if(hasReleventFormAttribute && !hasButtonType && !hasResetType) {
|
|
if(!state.hasUnsafeLink) {
|
|
const link = button.attrs[getAttributeKey('formaction', button.attrs)];
|
|
updateUnsafeStatus(link, null, state, form, options);
|
|
}
|
|
|
|
if(!state.hasUnsafeTarget) {
|
|
const target = button.attrs[getAttributeKey('formtarget', button.attrs)];
|
|
updateUnsafeStatus(null, target, state, form, options);
|
|
}
|
|
}
|
|
|
|
if(state.done) {
|
|
break;
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
function getAttributeKey(attribute, attributes) {
|
|
if(!attributes) {
|
|
return undefined;
|
|
}
|
|
|
|
// Can't access key directly because of case sensitivity
|
|
for(const key of Object.keys(attributes)) {
|
|
if(key.toLowerCase() === attribute.toLowerCase()) {
|
|
return key;
|
|
}
|
|
}
|
|
}
|
|
|
|
function hasWord(word, string) {
|
|
// Regex = start of string or whitespace + word + end of string or whitespace
|
|
return new RegExp(String.raw`(?:^|\s)${word}(?:$|\s)`, 'i').test(string);
|
|
}
|
|
|
|
function getUnsafeTargetStatus(target) {
|
|
if(target) {
|
|
const safeTargets = ['_parent', '_self', '_top'];
|
|
|
|
target = !safeTargets.includes(target.trim().toLowerCase());
|
|
}
|
|
|
|
return target;
|
|
}
|
|
|
|
function updateUnsafeStatus(link, target, state, node, options) {
|
|
if(link && !state.hasUnsafeLink) {
|
|
const hasExternalLink = getExternalLinkStatus(link);
|
|
const isIgnored = options.ignore && options.ignore.test(link);
|
|
|
|
if(hasExternalLink && !isIgnored) {
|
|
state.hasUnsafeLink = true;
|
|
}
|
|
}
|
|
|
|
if(state.hasUnsafeLink && options.noreferrer) {
|
|
addRel(node, options, state);
|
|
return;
|
|
}
|
|
|
|
if(target && !state.hasUnsafeTarget) {
|
|
state.hasUnsafeTarget = getUnsafeTargetStatus(target);
|
|
}
|
|
|
|
if(state.hasUnsafeLink && options.noopener && !state.hasNoopener && state.hasUnsafeTarget) {
|
|
addRel(node, options, state);
|
|
return;
|
|
}
|
|
|
|
function addRel(node, options, state) {
|
|
if(!node.attrs) {
|
|
node.attrs = {};
|
|
}
|
|
|
|
addRelAttribute(node.attrs, options);
|
|
state.done = true;
|
|
}
|
|
}
|
|
|
|
function getExternalLinkStatus(link) {
|
|
if(link) {
|
|
link = link.trim();
|
|
// Ensure that an existing link on the page isn't matched
|
|
const randomDomain = 'automaticnoopenerplugin' + Math.floor(Math.random() * 89999 + 10000);
|
|
const isInternalLink = new URL(link, `https://${randomDomain}.com/`).host === `${randomDomain}.com`;
|
|
|
|
link = !isInternalLink;
|
|
}
|
|
|
|
return link;
|
|
}
|
|
|
|
function addRelAttribute(attributes, options) {
|
|
const relKey = getAttributeKey('rel', attributes);
|
|
let newValue;
|
|
|
|
if(options.noreferrer) {
|
|
newValue = 'noreferrer';
|
|
}
|
|
else if(options.noopener) {
|
|
newValue = 'noopener';
|
|
}
|
|
|
|
if(!relKey) {
|
|
attributes.rel = newValue;
|
|
}
|
|
else {
|
|
// Regex = non-whitespace at end of string
|
|
if(/\S$/.test(attributes[relKey])) {
|
|
newValue = ' ' + newValue;
|
|
}
|
|
|
|
attributes[relKey] += newValue;
|
|
}
|
|
} |