<portal> is a fairly new HTML element that is currently supported only in Chrome Canary behind the #enable-portals flag. As stated in a recent article on portals published in web.dev, their main objective is to enable seamless transitions to the web by pre-rendering content in an <iframe>-like element that can be then “promoted” (activated) to a top-level frame.
Please see the web.dev article on this topic or see the specification if you wish to find out more. I will give a short summary of portals below anyway along with a security analysis. To keep you encouraged, the analysis includes a Same Origin Policy bypass as well as local file disclosure in Chrome that was rewarded with $10k bounty 🙂
Special shout-out to Frederik Braun as it was his tweet that directly motivated me to have a look at portals. Frederik pointed out that <portal> has already been shipped in Chrome Canary (albeit behind a flag) without “Security Considerations” figured out in the spec. This was interesting for me as it may have meant than either the potential security issues had not been carefully thought out, or they just hadn’t been put into the spec. So I decided to find out!
The issues outlined below might be useful to both the specification authors, as well as to any security researchers interested in analysing the security of <portal> element should other browser vendors decide to implement it as well. I am also releasing all examples shown in this article in GitHub repository: https://github.com/securitum/research/tree/master/r2019_security-analysis-of-portal-element
Note: the research was performed in May and June. The article describes the issue at the time of performing the research, however every item contains also information about the current state.
What is <portal>?
First things first, what even is <portal> and why do we need it? Basically, it’s a new HTML element that allows to embed content. It behaves significantly different from <iframe> though. The main differences are:
- You cannot access the DOM tree of a site embedded in <portal> from the embedder. This means no frames, no contentWindow/contentDocument and neither the named window access. This makes it possible to run content embedded in <portal> in a different event loop (in fact, Chrome Canary runs the embeded content in a new process).
- The only way to communicate between the embedder and the embedded content is asynchronous communication using postMessage calls.
- The embedded content actually works as if it was a top-level browsing context. This means that within the portal top === window is equal to true, even though it is an embedded page. The only way to find out if the page is rendered within a portal is to check if the global variable portalHost is not equal to null nor undefined.
- (This is the main feature of portals): The portal element can be promoted to top level context by calling a <portal>.activate() method. The content is not re-rendered, it just “jumps” from portal to the top frame. Have a look at the gif below. After executing portal.activate(), its content is not re-rendered but immediately becomes a top frame. This is a unique feature offered by <portal>.
An example HTML using portal:
<!doctype html><meta charset=utf-8>
<portal src=https://securitum.pl id=portal></portal>
<portal> security risks
Another direct consequence of <portal> behaving like a top-level frame are ClickJacking issues. Browsers don’t account X-Frame-Options for top frames. Does that mean that <portal> created a new, easy way for Clickjacking?
In the sections below I’ll answer these questions along with some other ones that popped out during my research.
RISK 1: Accepting unsafe URI schemes
When user inputs the address manually into the address bar, she or he is generally allowed to visit a wide range of URI schemes. Some of the examples are: http:, https:, file:, chrome: or data:. While the schemes could be visited manually, websites are not allowed to redirect users to schemes other than http or https in the top-level frame (this is not exactly true but let’s simplify things a bit). For instance, if a page tried to redirect user to file:///etc/passwd, Chrome would throw an exception:
Not allowed to load local resource: file:///etc/passwd
When doing my first tests, it turned out that the same restriction doesn’t apply to <portal> and I could open any page I wanted, including file: or even chrome: schemes.
The video below shows a comparison between <iframe> and <portal>. Both file: and chrome: schemes are displayed in <portal>.
Note: The page you can see in the gif is called portal-playground. You can find it in the GitHub repo on https://github.com/securitum/research/blob/master/r2019_security-analysis-of-portal-element/portal-playground.html. You can play a little bit with portals in it.
portal.src = 'https://google.com'
// and after a while...
The gif below showcases how I was able to steal data from both https://accounts.google.com as well as from file:///etc/passwd:
Please see the exploit code to find out exactly how it works.
The bug was reported to Google on 13th May 2019 and confirmed to be fixed on 26th May 2019 as https://crbug.com/962500. The fix was to add a check that the source of a portal is in the HTTP family.
Another interesting side effect of being able to open arbitrary URI in portal and then to activate it was the ability to open data: URL in the top-frame (I tweeted about it during my research). For some time now, both Chrome and Firefox has a protection that you cannot open data: URL in top-level frame. If you try do that, you’ll get an exception:
Not allowed to navigate top frame to data URL: data:,text
Interestingly, when you open data: URI in portal and then activate it… you have data URL in the top frame!
This is also fixed in Chrome. Since you cannot open non-HTTP(s) URL-s in portal, you cannot also navigate to data URL.
RISK 2: Clickjacking
Clickjacking is perhaps the most obvious risk associated with <portal>. It was mainly defeated in iframes with X-Frame-Options response header. The fix is not so easy with portals as they currently ignore the header. It kind of makes sense because the embedded content works as it was a top level frame so X-Frame-Options shouldn’t be taken into consideration.
The solution for the issue currently employed by Chrome is to make the embeded content not receive mouse and touch events. So even if you click on the <portal> content – nothing happens. This is not explicitly stated in the spec but seems a sane approach.
Keyboard events should also be blocked but weren’t at the time of tests. So you could make a button inside a portal get focus, and when user pressed ENTER – the button clicked. You can see that in the video below:
At first, I’m trying to click the button a few times to no avail. Then I make the button get focus with a keyboard and after pressing ENTER, the button is clicked.
I noticed that portal content can get focus in two ways:
- First: if you set <portal contenteditable> then it gets focus after hitting TAB button.
- Second: if the embedded content contains an element with id, you can just point it in the URL: <portal src=page.html#id> and it will be focused.
This was also reported to Google as bug#967199 and remains unfixed as of Chrome 78.
In my opinion, the main job of a <portal> element is to show content of a page, not to be able to interact with it in any way, hence I think it should be explicitly spec’d that embedded content in <portal> should not receive any mouse, touch or key events whatsoever.
RISK 3: Other framing risks
Clickjacking is not the only risk associated with embedding content. The other risks were cleanly explained in a paper by Frederik Braun and Mario Heiderich called X-Frame-Options: All about Clickjacking?. In this section, I will show you just one attack that is possible with portals that you cannot protect against currently (that’s not entirely true but more on that later).
Suppose you go to some website and see a CAPTCHA like below:
It probably looks a little bit suspicious, but that could be fixed with splitting the code across a few CAPTCHAs. So what is the code actually? Well, there is a portal that points to https://account.shodan.io/ and displays the API key for Shodan.
You can find the example in repo: https://github.com/securitum/research/blob/master/r2019_security-analysis-of-portal-element/captcha.html
What Shodan and other similar websites can do to protect against showing keys as CAPTCHA on malicious sites? X-Frame-Options doesn’t work for portals. The only way is to check for portalHost global variable but it cannot be expected that all websites in the world would suddenly look for it.
I believe that there are two good solutions for the problem:
- Apply X-Frame-Options for <portal> similar to <iframe>. While it would make things much easier, the argument against it is that <portal> creates in fact a top-level frame which would be inconsistent with being blocked by X-Frame-Options.
- Make <portal> an opt-in feature. If developer of a website wanted the page to be embedded in portals, she or he would have to explicitly set a header like “Sec-Allow-In-Portal: 1”. That would also fix a bunch of other problems described in this article.
As of Chrome 78, it is not fixed.
RISK 4: XSSearch / XSLeaks
XSSearch (Cross Site Search) and XSLeaks (Cross Site Leaks) are two new, hot topics in browser security in recent months. The attacks are possible mainly by abusing browser side channels to deduce how another site behaved. You can read more about it in XSLeaks GitHub repo. I will show two examples in which <portal> makes things easy: timing attacks and detecting XSS auditor.
Timing attacks are very easy with <portal> since it fires the onload event after the embedded content is loaded. So the attack is as simple as taking the time before loading portal and then subtracting it in onload event. Not respecting X-Frame-Options makes the attack even more valuable.
Detecting XSS auditor is also easy – the only thing that is needed is to count how many times onload event fired. If the auditor is configured to work in a block mode, then the onload event is fired twice. Otherwise, the event is fired only once. I prepared a simple code to check it and you can see it in action below:
As of Chrome 78 the XSS Auditor side-channel no longer works since it was removed from Chrome.
RISK 5: Port scanning
The ability to check the number of times onload event fired made me wonder if it was also possible to do a port scanning. And amazingly – it was! You could determine if a port is open for various network services, not only web servers.
The key was just to count the number of times onload event gets fired. And I was a little bit surprised when I found out that it depends on the exact reason the error page was shown. What happened is:
- When Chrome showed ERR_CONNECTION_REFUSED error, onload event fired 5 times,
- When Chrome showed ERR_INVALID_HTTP_RESPONSE or ERR_EMPTY_RESPONSE, onload event fired 4 times.
(In fact onload could have fired a different number of times but basically, you could tell the reason of an error basing on the number of onload events being even or odd).
Below is shown an example of a port scan on my server in which:
- Ports 3306 and 80 are open,
- Port 3307 is closed,
- Port 3308 is filtered.
And here’s the output from the port scanning function:
You can find the source of the port scanner in the repo: https://github.com/securitum/research/blob/master/r2019_security-analysis-of-portal-element/port-scan.html
As of Chrome 78, the attack still works.
RISK 6: Circumvent CSP
I had an idea that perhaps you could also abuse portals to circumvent Content-Security-Policy. Among many things you can achieve with CSP, you can also restrict what domains can be put in iframes in your domain with frame-src or child-src directive. I was almost sure that CSP would have no effect on portals…
But I was wrong! It appears that both frame-src and child-src are taken into account when displaying content within <portal>. This basically means that the following code will result in an error:
<meta http-equiv=Content-Security-Policy content="frame-src 'none'">
And here’s the error:
Despite the error message, it has nothing to do with extensions.
I think this is the right approach. Otherwise portals could be easily used to circumvent CSP and exfiltrate data.
RISK 7: SameSite cookies
SameSite is a flag for cookies that defends against CSRF attack as well as some other risks by making sure that a cookie with this flag set can only be sent to the same domain. If SameSite would be doing its job, then it should not be sent to a request initiated by <portal src=http://other-domain.tld></portal>.
I have set up a simple example to check it. I have a page that set three cookies:
- SAMESITE_STRICT – with flag SameSite=Strict,
- SAMESITE_LAX – with flag SameSite=Lax,
- NO_SAMESITE – without any SameSite attribute.
I then check both <iframe> and <portal> to see if those cookies are being sent. As you can see in the screenshot below, all three cookies are being sent to portal, while only NO_SAMESITE is being sent to iframe.
As of Chrome 78, the behaviour slightly changed: now the SAMESITE_STRICT cookie is not being sent to portal. The NO_SAMESITE cookies is not being sent to iframe while it still is for portal.
RISK 8: Downloading files
In Chrome, when you visit a page that downloads a file, it gets immediately downloaded: you can see it in the bar that shows in the bottom area of the window. If a page tries to be malicious and download multiple files at once, Chrome asks for an explicit permission, as seen in the screenshot:
When you open a page inside <portal> that downloads a file then the file gets immediately downloaded too. However, the protection against downloading multiple files no longer works. You can just refresh the portal as many times as you want and Chrome happily downloads files unlimited number of times.
Below is shown an example:
Link to repo: https://github.com/securitum/research/blob/master/r2019_security-analysis-of-portal-element/download.html
As of Chrome 78, the attack still works.
RISK 9: Dangling Markup
<input name=csrftoken value=12345678secret type='hidden'>
Then everything between the opening and the closing single quote is sent out to external server. For some time now, Chrome has a built-in protection against this type of attack. So the above example will not work in current versions of Chrome.
For some reason, though, the same protection doesn’t work for <portal>. Which means that with the code shown below, you could still exfiltrate data:
<input name=csrftoken value=12345678secret type='hidden'>
You can play around with it with example in the repo: https://github.com/securitum/research/blob/master/r2019_security-analysis-of-portal-element/dangling-markup.html.
As of Chrome 78, the attack still works.
In this write-up I have described the new <portal> element currently supported only in Chrome Canary. Being inspired by the fact that currently the specification lacks any security considerations, I have covered various security issues that might arise from using portals, showing that currently there’s still a lot to improve.
The list of issues described in this write-up are probably not exhaustive. Hence I’m very curious about your thoughts and other security issues you might think of when analysing portals 🙂