Managing External Links Safely in Angular
When building large enterprise Angular applications, there are times where the user may need to leave the application temporarily to complete some task. Often when we provide a link to a separate domain, we can apply a particular attribute target="_blank"
to open that link in a new tab. Opening a link in a new tab can be helpful to keep the application running and not lose work in progress.
<a href="https://example.com" target="_blank">
Example External Link
</a>
While opening a link in a new tab can be convenient, it can pose a security and performance risk. When using target="_blank"
the referring domain runs on the same process as your application which causes two issues. First, by being on the same process, the referring domain can access details about your application via the window.opener
property and bypasses the default isolated behavior of browser tabs. The other issue is a performance problem as now your application and the referring link are running on the same process and have to share resources.
To solve both of these issues, any link using target="_blank"
should also have a rel="noopener"
attribute as well. The rel="noopener"
attribute makes sure that the new browser tab does not run on the same process and prevents it from accessing window.opener
.
<a href="https://example.com" target="_blank" rel="noopener">
Example External Link
</a>
This attribute fixes our performance and security issues. Still, now we must manually check every link in our application to ensure that it is external that it has the appropriate attributes. Luckily for us, we can leverage Angular Directives to automate this.
Angular Directive
Angular Directives typically are attribute-based selectors. With Directives, however, you can use any valid CSS selector.
import { Directive } from '@angular/core';
@Directive({
selector: 'a[href]'
})
export class ExternalLinkDirective {}
In the example Directive, we can select all anchor tags with an href
attribute. This selector allows us to inspect all links in our application and determine how we should update them.
import { Directive, HostBinding, Input } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
@Directive({
selector: 'a[href]'
})
export class ExternalLinkDirective {
@HostBinding('attr.rel') relAttr = '';
@HostBinding('attr.target') targetAttr = '';
@HostBinding('attr.href') hrefAttr = '';
@Input() href: string;
ngOnChanges() {
this.hrefAttr = this.href;
if (this.isLinkExternal()) {
this.relAttr = 'noopener';
this.targetAttr = '_blank';
}
}
private isLinkExternal() {
return !this.href.includes(location.hostname);
}
}
Using the HostBinding
decorator, we can access the attributes of the host element, which is the link to the directive is attached. In this use case, we access the rel
, target
, and href
attributes.
Using the @Input
decorator, we can get the value of the href
on the link.Because we set the href
as an input, then whenever the href
changes the ngOnChanges
life cycle will be called. We need to hook into ngOnChanges
that way if the link id dynamically created, our directive knows to check the href
value and update.
import { Component } from '@angular/core';
@Component({
selector: 'my-app',
template: `<a href="https://{{dynamic}}">Dynamic Link</a>`
})
export class AppComponent {
dynamic = 'example.com';
}
When taking in the href as an @Input
Angular stops reflecting or updating the DOM href
attribute on our links. Because of this, we have to reassign href
attribute, so it's updated in the DOM.
export class ExternalLinkDirective {
...
@HostBinding('attr.href') hrefAttr = '';
@Input() href: string;
ngOnChanges() {
this.hrefAttr = this.href;
...
}
}
Now anytime the input changes, we update the href
attribute in the DOM as well. In the ngOnChanges
, we check if the link is external or not by checking the current domain using the native browser API location.hostname
.
export class ExternalLinkDirective {
@HostBinding('attr.rel') relAttr = null;
@HostBinding('attr.target') targetAttr = null;
@Input() href: string;
ngOnChanges() {
this.elementRef.nativeElement.href = this.href;
if (this.isLinkExternal()) {
this.relAttr = 'noopener';
this.targetAttr = '_blank';
} else {
this.relAttr = '';
this.targetAttr = '';
}
}
private isLinkExternal() {
return !this.elementRef.nativeElement.hostname.includes(location.hostname);
}
}
If the link is external to our application, then we set the rel
and target
attributes appropriately. Now all links in our application are external when clicked open into a separate tab safely. There is a little more work to make this code safe for server-side rendering with Angular Universal.
import {
Directive,
HostBinding,
PLATFORM_ID,
Inject,
Input
} from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
@Directive({
selector: 'a[href]'
})
export class ExternalLinkDirective {
@HostBinding('attr.rel') relAttr = null;
@HostBinding('attr.target') targetAttr = null;
@Input() href: string;
constructor(
@Inject(PLATFORM_ID) private platformId: string,
private elementRef: ElementRef) {}
ngOnChanges() {
this.hrefAttr = this.href;
if (this.isLinkExternal()) {
this.relAttr = 'noopener';
this.targetAttr = '_blank';
}
}
private isLinkExternal() {
return isPlatformBrowser(this.platformId) && !this.elementRef.nativeElement.hostname.includes(location.hostname);
}
}
If this is running in a server-side application, we need to check that way we do not try to access the browser only APIs like location
. To check if we are running on the server, we inject the platformId
. This gives us a token value that we can pass to the isPlatformBrowser()
function. Now our code only runs on the client and prevents and errors on the server.
To see the full working example check out the demo link below. To get more information about the security and performance risks of using target="_blank"
check out the Google Developer Documentation.
Special thanks to @robertvhoesel for the improved parsing of the hostname url!