Improve user privacy with secure outbound links
All outbound links now include `rel="noopener noreferrer"` attribute. This security improvement prevents the new page from being able to access the `window.opener` property and ensures it runs in a separate process. `rel="noopener"`: When a new page is opened using `target="_blank"`, the new page runs on the same process as the originating page, and has a reference to the originating page `window.opener`. By implementing `rel="noopener"`, the new page is prevented to use `window.opener` property. It's security issue because the newly opened website could potentially redirect the page to a malicious URL. Even though privacy.sexy doesn't have any sensitive information to protect, this can still be a vector for phishing attacks. `rel="noreferrer"`: It implies features of `noopener`, and also prevents `Referer` header from being sent to the new page. Referer headers may include sensitive data, because they tell the new page the URL of the page the request is coming from.
This commit is contained in:
28
README.md
28
README.md
@@ -4,13 +4,13 @@
|
|||||||
|
|
||||||
<!-- markdownlint-disable MD033 -->
|
<!-- markdownlint-disable MD033 -->
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://undergroundwires.dev/donate?project=privacy.sexy">
|
<a href="https://undergroundwires.dev/donate?project=privacy.sexy" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="donation badge"
|
alt="donation badge"
|
||||||
src="https://undergroundwires.dev/img/badges/donate/flat.svg"
|
src="https://undergroundwires.dev/img/badges/donate/flat.svg"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/undergroundwires/privacy.sexy/blob/master/CONTRIBUTING.md">
|
<a href="https://github.com/undergroundwires/privacy.sexy/blob/master/CONTRIBUTING.md" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="contributions are welcome"
|
alt="contributions are welcome"
|
||||||
src="https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat"
|
src="https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat"
|
||||||
@@ -18,13 +18,13 @@
|
|||||||
</a>
|
</a>
|
||||||
<!-- Code quality -->
|
<!-- Code quality -->
|
||||||
<br />
|
<br />
|
||||||
<a href="https://lgtm.com/projects/g/undergroundwires/privacy.sexy/context:javascript">
|
<a href="https://lgtm.com/projects/g/undergroundwires/privacy.sexy/context:javascript" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="Language grade: JavaScript/TypeScript"
|
alt="Language grade: JavaScript/TypeScript"
|
||||||
src="https://img.shields.io/lgtm/grade/javascript/g/undergroundwires/privacy.sexy.svg?logo=lgtm&logoWidth=18"
|
src="https://img.shields.io/lgtm/grade/javascript/g/undergroundwires/privacy.sexy.svg?logo=lgtm&logoWidth=18"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://codeclimate.com/github/undergroundwires/privacy.sexy/maintainability">
|
<a href="https://codeclimate.com/github/undergroundwires/privacy.sexy/maintainability" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="Maintainability"
|
alt="Maintainability"
|
||||||
src="https://api.codeclimate.com/v1/badges/3a70b7ef602e2264342c/maintainability"
|
src="https://api.codeclimate.com/v1/badges/3a70b7ef602e2264342c/maintainability"
|
||||||
@@ -32,19 +32,19 @@
|
|||||||
</a>
|
</a>
|
||||||
<!-- Tests -->
|
<!-- Tests -->
|
||||||
<br />
|
<br />
|
||||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.unit.yaml">
|
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.unit.yaml" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="Unit tests status"
|
alt="Unit tests status"
|
||||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/unit-tests/badge.svg"
|
src="https://github.com/undergroundwires/privacy.sexy/workflows/unit-tests/badge.svg"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.integration.yaml">
|
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.integration.yaml" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="Integration tests status"
|
alt="Integration tests status"
|
||||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/integration-tests/badge.svg"
|
src="https://github.com/undergroundwires/privacy.sexy/workflows/integration-tests/badge.svg"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.e2e.yaml">
|
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.e2e.yaml" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="E2E tests status"
|
alt="E2E tests status"
|
||||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/e2e-tests/badge.svg"
|
src="https://github.com/undergroundwires/privacy.sexy/workflows/e2e-tests/badge.svg"
|
||||||
@@ -52,19 +52,19 @@
|
|||||||
</a>
|
</a>
|
||||||
<!-- Checks -->
|
<!-- Checks -->
|
||||||
<br />
|
<br />
|
||||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.quality.yaml">
|
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.quality.yaml" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="Quality checks status"
|
alt="Quality checks status"
|
||||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/quality-checks/badge.svg"
|
src="https://github.com/undergroundwires/privacy.sexy/workflows/quality-checks/badge.svg"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.security.yaml">
|
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.security.yaml" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="Security checks status"
|
alt="Security checks status"
|
||||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/security-checks/badge.svg"
|
src="https://github.com/undergroundwires/privacy.sexy/workflows/security-checks/badge.svg"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.build.yaml">
|
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.build.yaml" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="Build checks status"
|
alt="Build checks status"
|
||||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/build-checks/badge.svg"
|
src="https://github.com/undergroundwires/privacy.sexy/workflows/build-checks/badge.svg"
|
||||||
@@ -72,19 +72,19 @@
|
|||||||
</a>
|
</a>
|
||||||
<!-- Release -->
|
<!-- Release -->
|
||||||
<br />
|
<br />
|
||||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.git.yaml">
|
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.git.yaml" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="Git release status"
|
alt="Git release status"
|
||||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-git/badge.svg"
|
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-git/badge.svg"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.site.yaml">
|
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.site.yaml" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="Site release status"
|
alt="Site release status"
|
||||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-site/badge.svg"
|
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-site/badge.svg"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.desktop.yaml">
|
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.desktop.yaml" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="Desktop application release status"
|
alt="Desktop application release status"
|
||||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-desktop/badge.svg"
|
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-desktop/badge.svg"
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<!-- Others -->
|
<!-- Others -->
|
||||||
<br />
|
<br />
|
||||||
<a href="https://github.com/undergroundwires/bump-everywhere">
|
<a href="https://github.com/undergroundwires/bump-everywhere" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="Auto-versioned by bump-everywhere"
|
alt="Auto-versioned by bump-everywhere"
|
||||||
src="https://github.com/undergroundwires/bump-everywhere/blob/master/badge.svg?raw=true"
|
src="https://github.com/undergroundwires/bump-everywhere/blob/master/badge.svg?raw=true"
|
||||||
|
|||||||
@@ -124,33 +124,50 @@ function isGoodPathPart(part: string): boolean {
|
|||||||
&& !/^[0-9a-f]{40}$/.test(part); // Git SHA (e.g. GitHub links)
|
&& !/^[0-9a-f]{40}$/.test(part); // Git SHA (e.g. GitHub links)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ExternalAnchorElementAttributes: Record<string, string> = {
|
||||||
|
target: '_blank',
|
||||||
|
rel: 'noopener noreferrer',
|
||||||
|
};
|
||||||
|
|
||||||
function openUrlsInNewTab(md: MarkdownIt) {
|
function openUrlsInNewTab(md: MarkdownIt) {
|
||||||
// https://github.com/markdown-it/markdown-it/blob/12.2.0/docs/architecture.md#renderer
|
// https://github.com/markdown-it/markdown-it/blob/12.2.0/docs/architecture.md#renderer
|
||||||
const defaultRender = getDefaultRenderer(md, 'link_open');
|
const defaultRender = getOrDefaultRenderer(md, 'link_open');
|
||||||
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
|
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
|
||||||
const token = tokens[idx];
|
const token = tokens[idx];
|
||||||
if (!getTokenAttributeValue(token, 'target')) {
|
|
||||||
token.attrPush(['target', '_blank']);
|
Object.entries(ExternalAnchorElementAttributes).forEach(([name, value]) => {
|
||||||
}
|
const currentValue = getAttribute(token, name);
|
||||||
|
if (!currentValue) {
|
||||||
|
token.attrPush([name, value]);
|
||||||
|
} else if (currentValue !== value) {
|
||||||
|
setAttribute(token, name, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
return defaultRender(tokens, idx, options, env, self);
|
return defaultRender(tokens, idx, options, env, self);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultRenderer(md: MarkdownIt, ruleName: string): Renderer.RenderRule {
|
function getOrDefaultRenderer(md: MarkdownIt, ruleName: string): Renderer.RenderRule {
|
||||||
const renderer = md.renderer.rules[ruleName];
|
const renderer = md.renderer.rules[ruleName];
|
||||||
if (renderer) {
|
return renderer || defaultRenderer;
|
||||||
return renderer;
|
function defaultRenderer(tokens, idx, options, _env, self) {
|
||||||
}
|
|
||||||
return (tokens, idx, options, _env, self) => {
|
|
||||||
return self.renderToken(tokens, idx, options);
|
return self.renderToken(tokens, idx, options);
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTokenAttributeValue(token: Token, attributeName: string): string | undefined {
|
function getAttribute(token: Token, name: string): string | undefined {
|
||||||
const attributeIndex = token.attrIndex(attributeName);
|
const attributeIndex = token.attrIndex(name);
|
||||||
if (attributeIndex < 0) {
|
if (attributeIndex < 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const value = token.attrs[attributeIndex][1];
|
const value = token.attrs[attributeIndex][1];
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setAttribute(token: Token, name: string, value: string): void {
|
||||||
|
const attributeIndex = token.attrIndex(name);
|
||||||
|
if (attributeIndex < 0) {
|
||||||
|
throw new Error('Attribute does not exist');
|
||||||
|
}
|
||||||
|
token.attrs[attributeIndex][1] = value;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<div>Sorry, no matches for "{{this.searchQuery | threeDotsTrim }}" 😞</div>
|
<div>Sorry, no matches for "{{this.searchQuery | threeDotsTrim }}" 😞</div>
|
||||||
<div>
|
<div>
|
||||||
Feel free to extend the scripts
|
Feel free to extend the scripts
|
||||||
<a :href="repositoryUrl" target="_blank" class="child github">here</a> ✨
|
<a :href="repositoryUrl" class="child github" target="_blank" rel="noopener noreferrer">here</a> ✨
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,13 +19,13 @@
|
|||||||
<div class="line__emoji">🤖</div>
|
<div class="line__emoji">🤖</div>
|
||||||
<div>
|
<div>
|
||||||
All transparent: Deployed automatically from the master branch
|
All transparent: Deployed automatically from the master branch
|
||||||
of the <a :href="repositoryUrl" target="_blank">source code</a> with no changes.
|
of the <a :href="repositoryUrl" target="_blank" rel="noopener noreferrer">source code</a> with no changes.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isDesktop" class="line">
|
<div v-if="!isDesktop" class="line">
|
||||||
<div class="line__emoji">📈</div>
|
<div class="line__emoji">📈</div>
|
||||||
<div>
|
<div>
|
||||||
Basic <a href="https://aws.amazon.com/cloudfront/reporting/" target="_blank">CDN statistics</a>
|
Basic <a href="https://aws.amazon.com/cloudfront/reporting/" target="_blank" rel="noopener noreferrer">CDN statistics</a>
|
||||||
are collected by AWS but they cannot be traced to you or your behavior.
|
are collected by AWS but they cannot be traced to you or your behavior.
|
||||||
You can download the offline version if you don't want any CDN data collection.
|
You can download the offline version if you don't want any CDN data collection.
|
||||||
</div>
|
</div>
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
<div>
|
<div>
|
||||||
As almost no data is collected, the application gets better
|
As almost no data is collected, the application gets better
|
||||||
only with your active feedback.
|
only with your active feedback.
|
||||||
Feel free to <a :href="feedbackUrl" target="_blank">create an issue</a> 😊</div>
|
Feel free to <a :href="feedbackUrl" target="_blank" rel="noopener noreferrer">create an issue</a> 😊</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<span v-if="isDesktop" class="footer__section__item">
|
<span v-if="isDesktop" class="footer__section__item">
|
||||||
<font-awesome-icon class="icon" :icon="['fas', 'globe']" />
|
<font-awesome-icon class="icon" :icon="['fas', 'globe']" />
|
||||||
<span>
|
<span>
|
||||||
Online version at <a :href="homepageUrl" target="_blank">{{ homepageUrl }}</a>
|
Online version at <a :href="homepageUrl" target="_blank" rel="noopener noreferrer">{{ homepageUrl }}</a>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="footer__section__item">
|
<span v-else class="footer__section__item">
|
||||||
@@ -14,19 +14,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="footer__section">
|
<div class="footer__section">
|
||||||
<div class="footer__section__item">
|
<div class="footer__section__item">
|
||||||
<a :href="feedbackUrl" target="_blank">
|
<a :href="feedbackUrl" target="_blank" rel="noopener noreferrer">
|
||||||
<font-awesome-icon class="icon" :icon="['far', 'smile']" />
|
<font-awesome-icon class="icon" :icon="['far', 'smile']" />
|
||||||
<span>Feedback</span>
|
<span>Feedback</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer__section__item">
|
<div class="footer__section__item">
|
||||||
<a :href="repositoryUrl" target="_blank">
|
<a :href="repositoryUrl" target="_blank" rel="noopener noreferrer">
|
||||||
<font-awesome-icon class="icon" :icon="['fab', 'github']" />
|
<font-awesome-icon class="icon" :icon="['fab', 'github']" />
|
||||||
<span>Source Code</span>
|
<span>Source Code</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer__section__item">
|
<div class="footer__section__item">
|
||||||
<a :href="releaseUrl" target="_blank">
|
<a :href="releaseUrl" target="_blank" rel="noopener noreferrer">
|
||||||
<font-awesome-icon class="icon" :icon="['fas', 'tag']" />
|
<font-awesome-icon class="icon" :icon="['fas', 'tag']" />
|
||||||
<span>v{{ version }}</span>
|
<span>v{{ version }}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -10,17 +10,53 @@ describe('MarkdownRenderer', () => {
|
|||||||
// assert
|
// assert
|
||||||
expect(renderer !== undefined);
|
expect(renderer !== undefined);
|
||||||
});
|
});
|
||||||
it('opens URLs in new tab', () => {
|
describe('sets expected anchor attributes', () => {
|
||||||
// arrange
|
const attributes: ReadonlyArray<{
|
||||||
const renderer = createRenderer();
|
readonly name: string,
|
||||||
const markdown = '[undergroundwires.dev](https://undergroundwires.dev)';
|
readonly expectedValue: string,
|
||||||
// act
|
readonly invalidMarkdown: string
|
||||||
const htmlString = renderer.render(markdown);
|
}> = [
|
||||||
// assert
|
{
|
||||||
const html = parseHtml(htmlString);
|
name: 'target',
|
||||||
const aElement = html.getElementsByTagName('a')[0];
|
expectedValue: '_blank',
|
||||||
const href = aElement.getAttribute('target');
|
invalidMarkdown: '<a href="https://undergroundwires.dev" target="_self">example</a>',
|
||||||
expect(href).to.equal('_blank');
|
},
|
||||||
|
{
|
||||||
|
name: 'rel',
|
||||||
|
expectedValue: 'noopener noreferrer',
|
||||||
|
invalidMarkdown: '<a href="https://undergroundwires.dev" rel="nooverride">example</a>',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const attribute of attributes) {
|
||||||
|
const { name, expectedValue, invalidMarkdown } = attribute;
|
||||||
|
|
||||||
|
it(`adds "${name}" attribute to anchor elements`, () => {
|
||||||
|
// arrange
|
||||||
|
const renderer = createRenderer();
|
||||||
|
const markdown = '[undergroundwires.dev](https://undergroundwires.dev)';
|
||||||
|
|
||||||
|
// act
|
||||||
|
const htmlString = renderer.render(markdown);
|
||||||
|
|
||||||
|
// assert
|
||||||
|
const html = parseHtml(htmlString);
|
||||||
|
const aElement = html.getElementsByTagName('a')[0];
|
||||||
|
expect(aElement.getAttribute(name)).to.equal(expectedValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`overrides existing "${name}" attribute`, () => {
|
||||||
|
// arrange
|
||||||
|
const renderer = createRenderer();
|
||||||
|
|
||||||
|
// act
|
||||||
|
const htmlString = renderer.render(invalidMarkdown);
|
||||||
|
|
||||||
|
// assert
|
||||||
|
const html = parseHtml(htmlString);
|
||||||
|
const aElement = html.getElementsByTagName('a')[0];
|
||||||
|
expect(aElement.getAttribute(name)).to.equal(expectedValue);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
it('does not convert single linebreak to <br>', () => {
|
it('does not convert single linebreak to <br>', () => {
|
||||||
// arrange
|
// arrange
|
||||||
|
|||||||
Reference in New Issue
Block a user