Skip to content

Commit

Permalink
Improve user privacy with secure outbound links
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
undergroundwires committed Aug 5, 2023
1 parent ff84f56 commit 291c3c8
Show file tree
Hide file tree
Showing 7 changed files with 99 additions and 46 deletions.
28 changes: 14 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,95 +4,95 @@
<!-- 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"
/>
</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"
/>
</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"
/>
</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"
/>
</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"
/>
</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"
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
6 changes: 3 additions & 3 deletions src/presentation/components/TheFooter/PrivacyPolicy.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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>
Expand Down
8 changes: 4 additions & 4 deletions src/presentation/components/TheFooter/TheFooter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand All @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,53 @@ describe('MarkdownRenderer', () => {
// assert
expect(renderer !== undefined);
});
it('opens URLs in new tab', () => {
// 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');
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];
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
Expand Down

0 comments on commit 291c3c8

Please sign in to comment.