epiphany/node_modules/eleventy-plugin-automatic-noopener/components/add-attributes.js

267 lines
7 KiB
JavaScript
Raw Normal View History

2023-12-09 22:48:07 -08:00
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;
}
}