The Challenge
The application does one - rather complicated - task, with lots of business logic. There was no way we could rewrite it to include it directly into the website code. And because the application comes with its own Javascript and CSS, we decided to use an iframe to embed the application with clean isolation.
The company maintaining that application provided us with a version - running as a Docker container - where they had stripped all extra elements like the navigation, so that it would visually fit within the website. There was however no way for us to customise anything within the application.
iframe security
The promise of an iframe is to keep a clean security boundary between embedding page and embedded content. This means that it is by design not possible to call Javascript across the boundary.
Because injecting iframes could potentially trick a user into submitting data to an attacker (clickjacking), as the iframe may be from a different origin than the main page. Thus, to even render the iframe, the browser checks the Content-Security-Policy (CSP) HTTP header. That header has a field frame-src to control what may be included as an iframe. With this, I allowed the domain of the application to be included in iframes.
Content-Security-Policy: frame-src https://my-embed.com;
But not only does the including page need to allow an iframe. The page to be embedded also needs to allow being included with the frame-ancestors attribute of the CSP header. As we run the application Docker image under our control, I was able to add that header in the proxy that runs before the Docker image:
Content-Security-Policy: frame-ancestors https://my-website.com;
Several things to note:
- If you have other CSP rules, merge them with the rules, nginx will overwrite the header and not add to it
- Both options also support "self" to allow embedding resp. being embedded with the same webserver
- Prior to the CSP becoming a standard, there was an unofficial header
X-Frame-Options, which is still supported by browsers Content-Security-Policymust be an actual HTTP header,<meta http-equiv=ā...ā>is ignored forContent-Security-Policy(and also ignored forX-Frame-Options).
Size of the iframe element
Now we get to the weird parts. To prevent multiple scrollbars, we need the iframe element to be exactly big enough for the embedded page. If it is too small, there is an additional scrollbar (or hidden content). If it is too large, there is odd whitespace.
The size of the element needs to be set on the iframe, owned by the parent. The dimensions of the content are however only known by the embedded application. HTML / CSS do not provide any means to let the parent page declare that it wants the iframe to have the ānecessary sizeā.
We ended up with a really convoluted way, which seems the only way to achieve this: sending messages from the child page to the parent. This problem spawned dedicated javascript libraries like iframe-resizer. We ended up reimplementing the logic in the React application, as it was so small that a dedicated library felt like overkill. Following the tutorial at iframe-height (which also has some interesting background on the discussion about iframes in the Whatwg), we came up with this code for the containing website:
// register an event listener for messages
window.addEventListener('message', receiveMessage, false);
// handle a message
function receiveMessage(event) {
const origin = event.origin || event.originalEvent.origin;
// we configure the expected domain to allow for this additional sanity check
if (expectedDomain !== origin) {
return;
}
if (!event.data.request || 'iframeResize' !== event.data.request) {
return;
}
// the id is known in the js class. we need to find the element that needs to be resized
const iframe = document.getElementById(`iframe-${id}`);
if (iframe) {
// pad the height a bit to avoid unnecessary tiny scrolling
iframe.style.height = (event.data.height + 20) + 'px';
// width could be handled the same way if necessary - in our case the width is fix
}
}
Now we need to make the embedded content send a message with its height. The Javascript for that is a bit verbose to allow for different browsers, but not complicated either:
(
function(document, window) {
if (undefined === parent || !document.addEventListener) {
return;
}
function init() {
let owner = null;
const width = Math.max(document.body.scrollWidth, document.body.offsetWidth, document.documentElement.clientWidth, document.documentElement.scrollWidth, document.documentElement.offsetWidth);
const height = Math.max(document.body.scrollHeight, document.body.offsetHeight, document.documentElement.clientHeight, document.documentElement.scrollHeight, document.documentElement.offsetHeight);
if (parent.postMessage) {
owner = parent;
} else if (parent.contentWindow && parent.contentWindow.postMessage) {
owner = parent.contentWindow;
} else {
return;
}
owner.postMessage({
'request' : 'iframeResize',
'width' : width,
'height' : height
}, '*');
}
if (document.readyState !== 'loading') {
window.setTimeout(init);
} else {
document.addEventListener('DOMContentLoaded', init);
}
// this is needed to also adjust the iframe if something (e.g. the Javascript of the application) changes the size of the iframe without an actual page reload.
const observer = new ResizeObserver(init);
observer.observe(document.body);
}
)(document, window);"
iframes with same origin
If the iframe comes from the same origin (= domain) as the parent page, Javascript can cross the boundary. From parent to child, there is a contentWindow property on the iframe element. From child to parent, there is window.parent. With same origin, those elements expose all things the window usually has. For different origins, they only expose the function postMessage for the secure separation.
Injecting content with Nginx
Remember how I said we canāt modify the application? That still holds true. If we would have loaded both applications from the same domain, we could have had the parent page add a listener inside the iframe to directly update dimensions as needed. But the application contained absolute paths for its assets, so providing it from a subfolder of the same domain would have been tricky and we had to run it on a separate domain.
I ended up injecting the above snippet of Javascript in the Nginx proxy that sits in front of the Docker container:
proxy_set_header Accept-Encoding ""; # make sure we get plain response for substitution to work
...
sub_filter_last_modified on;
sub_filter "</body>" "<script language='javascript'>${script}</script></body>";
sub_filter_once on;
...
proxy_pass https://my-embed.com$request_uri;
Now the embedded iframe communicates its size to the containing page, which adjusts the iframe size accordingly.
(Note that Nginx does not execute these statements in order. The sub_filter instructions apply to the response, wihle proxy_set_header and proxy_pass apply to the request.)
Alternatives
Web Components are a more lightweight solution to combine separate sources into one website. If what you need to integrate is just an element and not a whole application, they might be a better fit. My collegue Falk wrote about Web Components last week.
Bonus: Access control for the iframe content
Because the application is not under our control, we need to manage access to it. We told the supplier of the application to remove access control and simply allow items to be created and edited by ID. Of course, this means that the application must never be directly exposed to the internet, but only reachable through the proxy.
On the embedding side, we track which user is allowed what ids, and have Nginx do a pre-flight check against the website to get the access decision:
# at the beginning of the location for the main request to the embeded application
auth_request /auth;
location /auth {
# preflight authorization request with symfony
fastcgi_pass phpfcgi;
include /usr/local/openresty/nginx/conf/fastcgi_params;
fastcgi_param SCRIPT_FILENAME /app/public/index.php;
# forward the original request URI to allow our main application to verify access to the specific resource
fastcgi_param REQUEST_URI /embed-check$request_uri;
# body is not forwarded. we have to remove content length separately, otherwise PHP-FPM will wait for the body until the auth request times out.
fastcgi_pass_request_body off;
fastcgi_param CONTENT_LENGTH "";
fastcgi_param CONTENT_TYPE "";
internal;
}
If the call at /embed-check/... returns a 2xx status, Nginx continues with the request, otherwise it returns the response with the status code to the client, allowing for example to redirect to the login page. In my case, i return an empty response with status 204 if the user is allowed to access the specific resource.
On Symfony side, I use Symfony security to make sure the user is logged in. And then parse the path to know which item in the application the request wants to access, and check if the user has access. This leaks knowledge about the URL design of the embedded application, which is not avoidable for granular access control.