Commit a3f7b6aa authored by David Sveningsson's avatar David Sveningsson
Browse files

feat(rules): `allowed-links` support `include` and `exclude`

parent 0235314f
Pipeline #381418563 passed with stages
in 11 minutes and 40 seconds
......@@ -54,6 +54,35 @@ Array [
exports[`docs/rules/allowed-links.md inline validation: base-valid 1`] = `Array []`;
exports[`docs/rules/allowed-links.md inline validation: external-include 1`] = `
Array [
Object {
"errorCount": 1,
"filePath": "inline",
"messages": Array [
Object {
"column": 10,
"context": "external",
"line": 5,
"message": "External link to this destination is not allowed by current configuration",
"offset": 77,
"ruleId": "allowed-links",
"ruleUrl": "https://html-validate.org/rules/allowed-links.html",
"selector": "a > a",
"severity": 2,
"size": 17,
},
],
"source": "<!-- allowed -->
<a href=\\"//foo.example.net\\">
<!-- not allowed -->
<a href=\\"//bar.example.net\\">",
"warningCount": 0,
},
]
`;
exports[`docs/rules/allowed-links.md inline validation: external-invalid 1`] = `
Array [
Object {
......
......@@ -9,6 +9,11 @@ markup["absolute-invalid"] = `<a href="/foo">`;
markup["absolute-valid"] = `<a href="../foo">`;
markup["base-invalid"] = `<a href="foo">`;
markup["base-valid"] = `<a href="./foo">`;
markup["external-include"] = `<!-- allowed -->
<a href="//foo.example.net">
<!-- not allowed -->
<a href="//bar.example.net">`;
describe("docs/rules/allowed-links.md", () => {
it("inline validation: external-invalid", () => {
......@@ -59,4 +64,10 @@ describe("docs/rules/allowed-links.md", () => {
const report = htmlvalidate.validateString(markup["base-valid"]);
expect(report.results).toMatchSnapshot();
});
it("inline validation: external-include", () => {
expect.assertions(1);
const htmlvalidate = new HtmlValidate({"rules":{"allowed-links":["error",{"allowExternal":{"include":["^//foo.example.net"]}}]}});
const report = htmlvalidate.validateString(markup["external-include"]);
expect(report.results).toMatchSnapshot();
});
});
......@@ -43,6 +43,7 @@ This rule takes an optional object:
### `allowExternal`
By setting `allowExternal` to `false` any link to a external resource will be disallowed.
This can also be set to an object (see below regarding `include` and `exclude` lists).
<validate name="external-invalid" rules="allowed-links" allowed-links='{"allowExternal": false}'>
<a href="http://example.net/foo">
......@@ -55,6 +56,7 @@ By setting `allowExternal` to `false` any link to a external resource will be di
### `allowRelative`
By setting `allowRelative` to `false` any link with a relative url will be disallowed.
This can also be set to an object (see below regarding `include` and `exclude` lists).
<validate name="relative-invalid" rules="allowed-links" allowed-links='{"allowRelative": false}'>
<a href="../foo">
......@@ -67,6 +69,7 @@ By setting `allowRelative` to `false` any link with a relative url will be disal
### `allowAbsolute`
By setting `allowAbsolute` to `false` any link with a absolute url will be disallowed.
This can also be set to an object (see below regarding `include` and `exclude` lists).
<validate name="absolute-invalid" rules="allowed-links" allowed-links='{"allowAbsolute": false}'>
<a href="/foo">
......@@ -90,3 +93,35 @@ Effectively this also means that links to files in the same folder must use `./t
<validate name="base-valid" rules="allowed-links" allowed-links='{"allowBase": false}'>
<a href="./foo">
</validate>
### Using `include` and `exclude`
In addition to a boolean value `allowExternal`, `allowRelative` and `allowAbsolute` can also be set to an object with the `include` and `exclude` properties for a more fine-grained control of what link destinations should be considered valid.
Each property is a list of regular expressions matched against the link destination.
- When `include` is set each link must match at least one entry to be valid.
- When `exclude` is set each link must not match any entries to be valid.
- The two properties are not mutually exclusive, both can be enabled at the same time.
For instance, `allowExternal.include` can be used to set a whitelist of valid external links while disallowing all others.
In this case external links to `foo.example.net` is valid but any other would yield an error:
```json
{
"allowExternal": {
"include": ["^//foo.example.net"]
}
}
```
<validate name="external-include" rules="allowed-links" allowed-links='{"allowExternal": {"include": ["^//foo.example.net"]}}'>
<!-- allowed -->
<a href="//foo.example.net">
<!-- not allowed -->
<a href="//bar.example.net">
</validate>
## Version history
- %version% - Added support for `include` and `exclude`.
import HtmlValidate from "../htmlvalidate";
import "../jest";
import { processAttribute } from "../transform/mocks/attribute";
import { Style } from "./allowed-links";
import { Style, matchList, AllowList } from "./allowed-links";
describe("matchList", () => {
it("should match if no lists are present", () => {
expect.assertions(1);
const list: AllowList<RegExp> = {
include: null,
exclude: null,
};
expect(matchList("foo", list)).toBeTruthy();
});
it('should match if value is allowed by one "allow" regexp', () => {
expect.assertions(5);
const list: AllowList<RegExp> = {
include: [/^foo/, /^bar$/],
exclude: null,
};
expect(matchList("foo", list)).toBeTruthy();
expect(matchList("foobar", list)).toBeTruthy();
expect(matchList("bar", list)).toBeTruthy();
expect(matchList("barfoo", list)).toBeFalsy();
expect(matchList("baz", list)).toBeFalsy();
});
it('should match if value is not disallowed by any "disallow" regexp', () => {
expect.assertions(5);
const list: AllowList<RegExp> = {
include: null,
exclude: [/^foo/, /^bar$/],
};
expect(matchList("foo", list)).toBeFalsy();
expect(matchList("foobar", list)).toBeFalsy();
expect(matchList("bar", list)).toBeFalsy();
expect(matchList("barfoo", list)).toBeTruthy();
expect(matchList("baz", list)).toBeTruthy();
});
it('should match if value if both "allow" and "disallow" matches', () => {
expect.assertions(5);
const list: AllowList<RegExp> = {
include: [/^foo/],
exclude: [/bar$/],
};
expect(matchList("foo", list)).toBeTruthy(); // prefix allowed
expect(matchList("foobar", list)).toBeFalsy(); // suffix disallowd
expect(matchList("foobaz", list)).toBeTruthy(); // prefix allowed
expect(matchList("bar", list)).toBeFalsy(); // prefix not allowed
expect(matchList("barfoo", list)).toBeFalsy(); // prefix not allowed
});
});
describe("rule allowed-links", () => {
let htmlvalidate: HtmlValidate;
......@@ -70,19 +120,19 @@ describe("rule allowed-links", () => {
expect(report).toHaveError("allowed-links", "Link destination must not be external url");
});
it("should not report error link is absolute", () => {
it("should not report error when link is absolute", () => {
expect.assertions(1);
const report = htmlvalidate.validateString('<a href="/foo"></a>');
expect(report).toBeValid();
});
it("should not report error link is relative to path", () => {
it("should not report error when link is relative to path", () => {
expect.assertions(1);
const report = htmlvalidate.validateString('<a href="./foo"></a>');
expect(report).toBeValid();
});
it("should not report error link is relative to base", () => {
it("should not report error when link is relative to base", () => {
expect.assertions(1);
const report = htmlvalidate.validateString('<a href="foo"></a>');
expect(report).toBeValid();
......@@ -109,20 +159,20 @@ describe("rule allowed-links", () => {
expect(report).toBeValid();
});
it("should not report error link is absolute", () => {
it("should not report error when link is absolute", () => {
expect.assertions(1);
const report = htmlvalidate.validateString('<a href="/foo"></a>');
expect(report).toBeValid();
});
it("should report error link is relative to path", () => {
it("should report error when link is relative to path", () => {
expect.assertions(2);
const report = htmlvalidate.validateString('<a href="./foo"></a>');
expect(report).toBeInvalid();
expect(report).toHaveError("allowed-links", "Link destination must not be relative url");
});
it("should report error link is relative to base", () => {
it("should report error when link is relative to base", () => {
expect.assertions(2);
const report = htmlvalidate.validateString('<a href="foo"></a>');
expect(report).toBeInvalid();
......@@ -150,19 +200,19 @@ describe("rule allowed-links", () => {
expect(report).toBeValid();
});
it("should not report error link is absolute", () => {
it("should not report error when link is absolute", () => {
expect.assertions(1);
const report = htmlvalidate.validateString('<a href="/foo"></a>');
expect(report).toBeValid();
});
it("should not report error link is relative to path", () => {
it("should not report error when link is relative to path", () => {
expect.assertions(1);
const report = htmlvalidate.validateString('<a href="./foo"></a>');
expect(report).toBeValid();
});
it("should report error link is relative to base", () => {
it("should report error when link is relative to base", () => {
expect.assertions(2);
const report = htmlvalidate.validateString('<a href="foo"></a>');
expect(report).toBeInvalid();
......@@ -193,26 +243,206 @@ describe("rule allowed-links", () => {
expect(report).toBeValid();
});
it("should report error link is absolute", () => {
it("should report error when link is absolute", () => {
expect.assertions(2);
const report = htmlvalidate.validateString('<a href="/foo"></a>');
expect(report).toBeInvalid();
expect(report).toHaveError("allowed-links", "Link destination must not be absolute url");
});
it("should not report error link is relative to path", () => {
it("should not report error when link is relative to path", () => {
expect.assertions(1);
const report = htmlvalidate.validateString('<a href="./foo"></a>');
expect(report).toBeValid();
});
it("should report error link is relative to base", () => {
it("should report error when link is relative to base", () => {
expect.assertions(1);
const report = htmlvalidate.validateString('<a href="foo"></a>');
expect(report).toBeValid();
});
});
describe("include", () => {
beforeAll(() => {
htmlvalidate = new HtmlValidate({
root: true,
rules: {
"allowed-links": [
"error",
{
allowExternal: { include: ["^//example.net"] },
allowRelative: { include: ["\\.png$"] },
allowAbsolute: { include: ["^/foobar/"] },
},
],
},
});
});
it("should report error when external link is not allowed", () => {
expect.assertions(2);
const markup = '<a href="//example.org/foo"></a>';
const report = htmlvalidate.validateString(markup);
expect(report).toBeInvalid();
expect(report).toHaveError(
"allowed-links",
"External link to this destination is not allowed by current configuration"
);
});
it("should report error when relative link is not allowed", () => {
expect.assertions(2);
const markup = '<img src="../foo.jpg">';
const report = htmlvalidate.validateString(markup);
expect(report).toBeInvalid();
expect(report).toHaveError(
"allowed-links",
"Relative link to this destination is not allowed by current configuration"
);
});
it("should report error when base relative link is not allowed", () => {
expect.assertions(2);
const markup = '<img src="foo.jpg">';
const report = htmlvalidate.validateString(markup);
expect(report).toBeInvalid();
expect(report).toHaveError(
"allowed-links",
"Relative link to this destination is not allowed by current configuration"
);
});
it("should report error when absolute link is not allowed", () => {
expect.assertions(2);
const markup = '<a href="/folder"></a>';
const report = htmlvalidate.validateString(markup);
expect(report).toBeInvalid();
expect(report).toHaveError(
"allowed-links",
"Absolute link to this destination is not allowed by current configuration"
);
});
it("should not report error when external link is allowed", () => {
expect.assertions(1);
const markup = '<a href="//example.net/foo"></a>';
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it("should not report error when relative link is allowed", () => {
expect.assertions(1);
const markup = '<img src="../foo.png">';
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it("should not report error when base relative link is allowed", () => {
expect.assertions(1);
const markup = '<img src="foo.png">';
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it("should not report error when absolute link is allowed", () => {
expect.assertions(1);
const markup = '<a href="/foobar/baz"></a>';
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
});
describe("exclude", () => {
beforeAll(() => {
htmlvalidate = new HtmlValidate({
root: true,
rules: {
"allowed-links": [
"error",
{
allowExternal: { exclude: ["^//example.net"] },
allowRelative: { exclude: ["\\.png$"] },
allowAbsolute: { exclude: ["^/foobar/"] },
},
],
},
});
});
it("should report error when external link is not allowed", () => {
expect.assertions(2);
const markup = '<a href="//example.net/foo"></a>';
const report = htmlvalidate.validateString(markup);
expect(report).toBeInvalid();
expect(report).toHaveError(
"allowed-links",
"External link to this destination is not allowed by current configuration"
);
});
it("should report error when relative link is not allowed", () => {
expect.assertions(2);
const markup = '<img src="../foo.png">';
const report = htmlvalidate.validateString(markup);
expect(report).toBeInvalid();
expect(report).toHaveError(
"allowed-links",
"Relative link to this destination is not allowed by current configuration"
);
});
it("should report error when base relative link is not allowed", () => {
expect.assertions(2);
const markup = '<img src="foo.png">';
const report = htmlvalidate.validateString(markup);
expect(report).toBeInvalid();
expect(report).toHaveError(
"allowed-links",
"Relative link to this destination is not allowed by current configuration"
);
});
it("should report error when absolute link is not allowed", () => {
expect.assertions(2);
const markup = '<a href="/foobar/baz"></a>';
const report = htmlvalidate.validateString(markup);
expect(report).toBeInvalid();
expect(report).toHaveError(
"allowed-links",
"Absolute link to this destination is not allowed by current configuration"
);
});
it("should not report error when external link is allowed", () => {
expect.assertions(1);
const markup = '<a href="//example.org/foo"></a>';
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it("should not report error when relative link is allowed", () => {
expect.assertions(1);
const markup = '<img src="../foo.jpg">';
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it("should not report error when base relative link is allowed", () => {
expect.assertions(1);
const markup = '<img src="foo.jpg">';
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it("should not report error when absolute link is allowed", () => {
expect.assertions(1);
const markup = '<a href="/folder"></a>';
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
});
it("should contain documentation", () => {
expect.assertions(1);
htmlvalidate = new HtmlValidate({
......
......@@ -10,10 +10,18 @@ export const enum Style {
ANCHOR = "anchor",
}
/**
* @internal
*/
export interface AllowList<T> {
include: T[] | null;
exclude: T[] | null;
}
interface RuleOptions {
allowExternal: boolean;
allowRelative: boolean;
allowAbsolute: boolean;
allowExternal: boolean | AllowList<string>;
allowRelative: boolean | AllowList<string>;
allowAbsolute: boolean | AllowList<string>;
allowBase: boolean;
}
......@@ -39,25 +47,67 @@ const description: Record<Style, string | null> = {
[Style.ANCHOR]: null,
};
function parseAllow(value: boolean | AllowList<string>): boolean | AllowList<RegExp> {
if (typeof value === "boolean") {
return value;
}
return {
/* eslint-disable security/detect-non-literal-regexp */
include: value.include ? value.include.map((it) => new RegExp(it)) : null,
exclude: value.exclude ? value.exclude.map((it) => new RegExp(it)) : null,
/* eslint-enable security/detect-non-literal-regexp */
};
}
/**
* @internal
*/
export function matchList(value: string, list: AllowList<RegExp>): boolean {
if (list.include && !list.include.some((it) => it.test(value))) {
return false;
}
if (list.exclude && list.exclude.some((it) => it.test(value))) {
return false;
}
return true;
}
export default class AllowedLinks extends Rule<Style, RuleOptions> {
protected allowExternal: boolean | AllowList<RegExp>;
protected allowRelative: boolean | AllowList<RegExp>;
protected allowAbsolute: boolean | AllowList<RegExp>;
public constructor(options: Partial<RuleOptions>) {
super({ ...defaults, ...options });
this.allowExternal = parseAllow(this.options.allowExternal);
this.allowRelative = parseAllow(this.options.allowRelative);
this.allowAbsolute = parseAllow(this.options.allowAbsolute);
}
public static schema(): SchemaObject {
const booleanOrObject = {
anyOf: [
{ type: "boolean" },
{
type: "object",
properties: {
include: {
type: "array",
items: { type: "string" },
},
exclude: {
type: "array",
items: { type: "string" },
},
},
},
],
};
return {
allowAbsolute: {
type: "boolean",
},
allowBase: {
type: "boolean",
},
allowExternal: {
type: "boolean",
},
allowRelative: {
type: "boolean",
},
allowExternal: { ...booleanOrObject },
allowRelative: { ...booleanOrObject },
allowAbsolute: { ...booleanOrObject },
allowBase: { type: "boolean" },
};
}
......@@ -85,19 +135,19 @@ export default class AllowedLinks extends Rule<Style, RuleOptions> {
break;
case Style.ABSOLUTE:
this.handleAbsolute(event, style);
this.handleAbsolute(link, event, style);
break;
case Style.EXTERNAL:
this.handleExternal(event, style);
this.handleExternal(link, event, style);
break;
case Style.RELATIVE_BASE:
this.handleRelativeBase(event, style);
this.handleRelativeBase(link, event, style);
break;
case Style.RELATIVE_PATH:
this.handleRelativePath(event, style);
this.handleRelativePath(link, event, style);
break;
}
});
......@@ -136,51 +186,76 @@ export default class AllowedLinks extends Rule<Style, RuleOptions> {
}
}
protected handleAbsolute(event: AttributeEvent, style: Style): void {
const { allowAbsolute } = this.options;
if (!allowAbsolute) {
protected handleAbsolute(target: string, event: AttributeEvent, style: Style): void {
const { allowAbsolute } = this;
if (allowAbsolute === true) {
return;
} else if (allowAbsolute === false) {
this.report(
event.target,
"Link destination must not be absolute url",
event.valueLocation,
style
);