Context
I'm trying to build a system in which a tool (the Client) will generate a header to be used as part of an HTTP request from the user's browser.
- The user should be able to choose their own implementation of the Client.
- The user should not have to install a plugin or extension to their browser.
Summary
A 3rd party will serve a small wrapper (the Shim) which will keep track of where to load the Client from. It will store this in the browser's IndexedDB under its own origin.
The Shim and the Client will be loaded in iframes of their own origin, so that they (and the Host website) can only access each-other's functionality through the defined methods (based on MessageChannels and postMessage() calls).
Parties, Prerogatives, and Restrictions
- The Host website is at www.host.com.
- They shouldn't be able to know what implementation of the Client is in use,
- nor should they be able to affect the Client in any way except by requesting header values.
- (As it stands the Host is able to suggest a default Client; when I actually build the set-Client-address tool for the Shim, I'll get rid of this.)
- The Shim is served from www.shim.com.
- They're assumed to be trustworthy in the sense that people know what code they're serving. (Even this isn't ideal; subresource integrity checks would be great if they worked on iframes.)
- Their code obviously knows who the Client is, and can see the requests and responses as they're passed back and forth, but all of this stays on the user's machine as far as the Shim is concerned.
- They can only affect the client by requesting the header values.
- The Client is served from www.client.com.
- At present this is being passed in as a default, but in practice it should have been saved into the IndexedDB on the user's machine belonging to the www.shim.com origin. (It will get put there by some other tool I haven't written yet.)
- It's fine for the client to know who the Host is; I may even add that as an explicit property of each request.
- There's an image or some other resource that the Host wants to load on their page, but requesting that image requires a custom
Receipts-Receipt
HTTPS header, the value for which needs to come from the Client. Let's suppose this image is at https://www.target.com/target.png, but it could just as easily be in the same origin as the Host website itself.
Code
www.host.com/host.html
<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8">
<title>Host</title>
<script
src="https://www.shim.com/shim.js"
data-default="https://www.client.com/client.html">
</script>
<script>
_page_loaded = ()=>{
window.FOTR.fetch(
new Request('https://www.target.com/target.png')
).then((response)=>{
return response.blob();
}).then((b)=>{
const i = window.document.createElement('img');
i.src = URL.createObjectURL(b);
document.getElementById("testTarget").appendChild(i);
});
};
</script>
</head>
<body onload="_page_loaded()">
<h1>Host</h1>
<p>Lorem ipsum dolor sit amet.</p>
<p id="testTarget"></p>
<p>Text continues.</p>
</body>
</html>
www.shim.com/shim.js
((toolName)=>{
if(typeof window[toolName] === 'undefined'){
const loaderUtilities = { //This is exactly the same between shim.html and shim.js.
domReady: new Promise((resolve, reject)=>{
if(document.readyState === "loading"){
document.addEventListener('DOMContentLoaded', resolve);
}
else{
resolve();
} // add error handler?
}),
origin: (uri)=>{
const parser = window.document.createElement('a');
parser.href = uri;
return `${parser.protocol}//${parser.host}`;
},
loadTool: (uri)=>{
return new Promise((resolve, reject)=>{
const tag = window.document.createElement('iframe');
tag.src = uri;
tag.width = 0;
tag.height = 0;
tag.style = "visibility: hidden";
window.addEventListener("message",
(e)=>{
if(e.origin == loaderUtilities.origin(uri)){ //is it possible to refine the origin check?
resolve(e.data);
}
},
false);
loaderUtilities.domReady.then(()=>{
document.body.appendChild(tag);
});
}); // add error handler?
},
requestOverPort: (port, resource)=>{
return new Promise((resolve, reject)=>{
const disposableChannel = new MessageChannel();
disposableChannel.port1.onmessage = (e)=>{
resolve(e.data);
disposableChannel.port1.close();
};
port.postMessage(
{
resource: resource,
port: disposableChannel.port2
},
[disposableChannel.port2]);
});
},
};
const defaultClient = document.currentScript.getAttribute("data-default") || '';
const gotClientPort = loaderUtilities.loadTool(`https://www.shim.com/shim.html#${defaultClient}`);
window[toolName] = {
fetch: (request)=>{
return gotClientPort
.then((clientPort)=>{
return loaderUtilities.requestOverPort(
clientPort,
{
url: request.url,
method: request.method
});
})
.then((receipt)=>{
return window.fetch(
request,
{
headers: new Headers({ 'Receipts-Receipt': receipt }),
});
});
}
}
}
})(document.currentScript.getAttribute("data-name") || "FOTR")
www.shim.com/shim.html
<!DOCTYPE html>
<html>
<head>
<title>The FOTR Shim</title>
<meta charset="UTF-8">
<script type="text/javascript">
const loaderUtilities = { //This is exactly the same between shim.html and shim.js.
domReady: new Promise((resolve, reject)=>{
if(document.readyState === "loading"){
document.addEventListener('DOMContentLoaded', resolve);
}
else{
resolve();
} // add error handler?
}),
origin: (uri)=>{
const parser = window.document.createElement('a');
parser.href = uri;
return `${parser.protocol}//${parser.host}`;
},
loadTool: (uri)=>{
return new Promise((resolve, reject)=>{
const tag = window.document.createElement('iframe');
tag.src = uri;
tag.width = 0;
tag.height = 0;
tag.style = "visibility: hidden";
window.addEventListener("message",
(e)=>{
if(e.origin == loaderUtilities.origin(uri)){ //is it possible to refine the origin check?
resolve(e.data);
}
},
false);
loaderUtilities.domReady.then(()=>{
document.body.appendChild(tag);
});
}); // add error handler?
},
requestOverPort: (port, resource)=>{
return new Promise((resolve, reject)=>{
const disposableChannel = new MessageChannel();
disposableChannel.port1.onmessage = (e)=>{
resolve(e.data);
disposableChannel.port1.close();
};
port.postMessage(
{
resource: resource,
port: disposableChannel.port2
},
[disposableChannel.port2]);
});
},
};
const indexedDBPromise = (request)=>{
return new Promise(
(resolve, reject)=>{
request.onsuccess = (e)=>{
resolve(e.target.result);
};
request.onerror = (e)=>{
reject(e.target.error);
};
});
};
const defaultClient = window.location.href.split('#')[1] || '';
const objectStoreName = "chosen_clients";
const openDB = window.indexedDB.open("FOTR", 1);
openDB.onupgradeneeded = (e)=>{
e.target.result.createObjectStore(objectStoreName);
};
indexedDBPromise(openDB)
.then(
(db)=>{
const tx = db.transaction(objectStoreName, "readonly");
tx.oncomplete = ()=>{
db.close();
};
return indexedDBPromise(tx.objectStore(objectStoreName).getAll());
})//handle open-db error?
.then(
(db_result)=>{
const clientURI = db_result.uri || defaultClient;
return loaderUtilities.loadTool(clientURI);
})//handle db-read error?
.then(
(innerPort)=>{
const outerChannel = new MessageChannel();
outerChannel.port1.onmessage = (e)=>{
const newPort = e.data.port;
const request = e.data.resource;
console.log(`Forwarding request for [${request.method}]${request.url}.`);
loaderUtilities.requestOverPort(innerPort, request)
.then((response)=>{
console.log(`Forwarding requested value "${response}" for [${request.method}]${request.url}`);
newPort.postMessage(response);
newPort.close();
});
};
window.parent.postMessage(outerChannel.port2, '*', [outerChannel.port2]);
});//handle tool-load error?
</script>
</head>
<body></body>
</html>
www.client.com/client.html
<!DOCTYPE html>
<html>
<head>
<title>The stupidest client.</title>
<meta charset="UTF-8">
<script type="text/javascript">
const pipe = new MessageChannel();
const work = (request)=>{
return "555";
};
pipe.port1.onmessage = (e)=>{
const newPort = e.data.port;
const request = e.data.resource;
const response = work(request);
console.log(`Receipt requested for [${request.method}]${request.url}; returning "${response}"`);
newPort.postMessage(response);
newPort.close();
};
window.parent.postMessage(pipe.port2, '*', [pipe.port2]); //should I be more restrictive of the recipient?
</script>
</head>
<body</body>
</html>
Comments
- The above works, in the sense that the image loads and the request for that image has the correct custom header.
- The Client presented is just a test rig that always returns '555'.
Question
Mostly I'm concerned with the security and usability of the Shim.
- Is the usage of iframes, Promises, indexedDB, and inter-frame messaging trustworthy? Am I doing them correctly?
- Does this Shim system provide the protections described in "Parties, Prerogatives, and Restrictions", insofar as any modern web-browser is secure?
- How should I approach error-handling?
- What else should I do to test this system?