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:
undergroundwires
2023-08-06 02:09:11 +02:00
parent ff84f5676e
commit 3a594ac7fd
6 changed files with 98 additions and 45 deletions

View File

@@ -4,13 +4,13 @@
<!-- markdownlint-disable MD033 -->
<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
alt="donation badge"
src="https://undergroundwires.dev/img/badges/donate/flat.svg"
/>
</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
alt="contributions are welcome"
src="https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat"
@@ -18,13 +18,13 @@
</a>
<!-- Code quality -->
<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
alt="Language grade: JavaScript/TypeScript"
src="https://img.shields.io/lgtm/grade/javascript/g/undergroundwires/privacy.sexy.svg?logo=lgtm&logoWidth=18"
/>
</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
alt="Maintainability"
src="https://api.codeclimate.com/v1/badges/3a70b7ef602e2264342c/maintainability"
@@ -32,19 +32,19 @@
</a>
<!-- Tests -->
<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
alt="Unit tests status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/unit-tests/badge.svg"
/>
</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
alt="Integration tests status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/integration-tests/badge.svg"
/>
</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
alt="E2E tests status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/e2e-tests/badge.svg"
@@ -52,19 +52,19 @@
</a>
<!-- Checks -->
<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
alt="Quality checks status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/quality-checks/badge.svg"
/>
</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
alt="Security checks status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/security-checks/badge.svg"
/>
</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
alt="Build checks status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/build-checks/badge.svg"
@@ -72,19 +72,19 @@
</a>
<!-- Release -->
<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
alt="Git release status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-git/badge.svg"
/>
</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
alt="Site release status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-site/badge.svg"
/>
</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
alt="Desktop application release status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-desktop/badge.svg"
@@ -92,7 +92,7 @@
</a>
<!-- Others -->
<br />
<a href="https://github.com/undergroundwires/bump-everywhere">
<a href="https://github.com/undergroundwires/bump-everywhere" target="_blank" rel="noopener noreferrer">
<img
alt="Auto-versioned by bump-everywhere"
src="https://github.com/undergroundwires/bump-everywhere/blob/master/badge.svg?raw=true"

View File

@@ -124,33 +124,50 @@ function isGoodPathPart(part: string): boolean {
&& !/^[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) {
// 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) => {
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);
};
}
function getDefaultRenderer(md: MarkdownIt, ruleName: string): Renderer.RenderRule {
function getOrDefaultRenderer(md: MarkdownIt, ruleName: string): Renderer.RenderRule {
const renderer = md.renderer.rules[ruleName];
if (renderer) {
return renderer;
}
return (tokens, idx, options, _env, self) => {
return renderer || defaultRenderer;
function defaultRenderer(tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options);
};
}
}
function getTokenAttributeValue(token: Token, attributeName: string): string | undefined {
const attributeIndex = token.attrIndex(attributeName);
function getAttribute(token: Token, name: string): string | undefined {
const attributeIndex = token.attrIndex(name);
if (attributeIndex < 0) {
return undefined;
}
const value = token.attrs[attributeIndex][1];
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;
}

View File

@@ -20,7 +20,7 @@
<div>Sorry, no matches for "{{this.searchQuery | threeDotsTrim }}" 😞</div>
<div>
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>

View File

@@ -19,13 +19,13 @@
<div class="line__emoji">🤖</div>
<div>
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 v-if="!isDesktop" class="line">
<div class="line__emoji">📈</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.
You can download the offline version if you don't want any CDN data collection.
</div>
@@ -35,7 +35,7 @@
<div>
As almost no data is collected, the application gets better
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>
</template>

View File

@@ -5,7 +5,7 @@
<span v-if="isDesktop" class="footer__section__item">
<font-awesome-icon class="icon" :icon="['fas', 'globe']" />
<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 v-else class="footer__section__item">
@@ -14,19 +14,19 @@
</div>
<div class="footer__section">
<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']" />
<span>Feedback</span>
</a>
</div>
<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']" />
<span>Source Code</span>
</a>
</div>
<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']" />
<span>v{{ version }}</span>
</a>

View File

@@ -10,17 +10,53 @@ describe('MarkdownRenderer', () => {
// assert
expect(renderer !== undefined);
});
it('opens URLs in new tab', () => {
describe('sets expected anchor attributes', () => {
const attributes: ReadonlyArray<{
readonly name: string,
readonly expectedValue: string,
readonly invalidMarkdown: string
}> = [
{
name: 'target',
expectedValue: '_blank',
invalidMarkdown: '<a href="https://undergroundwires.dev" target="_self">example</a>',
},
{
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];
const href = aElement.getAttribute('target');
expect(href).to.equal('_blank');
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>', () => {
// arrange