Bug 878941 - Add IdP proxy code with tests. r=abr

This commit is contained in:
Martin Thomson 2013-12-18 16:15:40 -08:00
parent a2007b5647
commit defc49d86e
11 changed files with 505 additions and 7 deletions

260
dom/media/IdpProxy.jsm Normal file
View File

@ -0,0 +1,260 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
this.EXPORTED_SYMBOLS = ["IdpProxy"];
const {
classes: Cc,
interfaces: Ci,
utils: Cu,
results: Cr
} = Components;
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Sandbox",
"resource://gre/modules/identity/Sandbox.jsm");
/**
* An invisible iframe for hosting the idp shim.
*
* There is no visible UX here, as we assume the user has already
* logged in elsewhere (on a different screen in the web site hosting
* the RTC functions).
*/
function IdpChannel(uri, messageCallback) {
this.sandbox = null;
this.messagechannel = null;
this.source = uri;
this.messageCallback = messageCallback;
}
IdpChannel.prototype = {
/**
* Create a hidden, sandboxed iframe for hosting the IdP's js shim.
*
* @param callback
* (function) invoked when this completes, with an error
* argument if there is a problem, no argument if everything is
* ok
*/
open: function(callback) {
if (this.sandbox) {
return callback(new Error("IdP channel already open"));
}
var ready = this._sandboxReady.bind(this, callback);
this.sandbox = new Sandbox(this.source, ready);
},
_sandboxReady: function(aCallback, aSandbox) {
// Inject a message channel into the subframe.
this.messagechannel = new aSandbox._frame.contentWindow.MessageChannel();
try {
Object.defineProperty(
aSandbox._frame.contentWindow.wrappedJSObject,
"rtcwebIdentityPort",
{
value: this.messagechannel.port2
}
);
} catch (e) {
this.close();
aCallback(e); // oops, the IdP proxy overwrote this.. bad
return;
}
this.messagechannel.port1.onmessage = function(msg) {
this.messageCallback(msg.data);
}.bind(this);
this.messagechannel.port1.start();
aCallback();
},
send: function(msg) {
this.messagechannel.port1.postMessage(msg);
},
close: function IdpChannel_close() {
if (this.sandbox) {
if (this.messagechannel) {
this.messagechannel.port1.close();
}
this.sandbox.free();
}
this.messagechannel = null;
this.sandbox = null;
}
};
/**
* A message channel between the RTC PeerConnection and a designated IdP Proxy.
*
* @param domain (string) the domain to load up
* @param protocol (string) Optional string for the IdP protocol
*/
function IdpProxy(domain, protocol) {
IdpProxy.validateDomain(domain);
IdpProxy.validateProtocol(protocol);
this.domain = domain;
this.protocol = protocol || "default";
this._reset();
}
/**
* Checks that the domain is only a domain, and doesn't contain anything else.
* Adds it to a URI, then checks that it matches perfectly.
*/
IdpProxy.validateDomain = function(domain) {
let message = "Invalid domain for identity provider; ";
if (!domain || typeof domain !== "string") {
throw new Error(message + "must be a non-zero length string");
}
message += "must only have a domain name and optionally a port";
try {
let ioService = Components.classes["@mozilla.org/network/io-service;1"]
.getService(Components.interfaces.nsIIOService);
let uri = ioService.newURI('https://' + domain + '/', null, null);
// this should trap errors
// we could check uri.userPass, uri.path and uri.ref, but there is no need
if (uri.hostPort !== domain) {
throw new Error(message);
}
} catch (e if (e.result === Cr.NS_ERROR_MALFORMED_URI)) {
throw new Error(message);
}
};
/**
* Checks that the IdP protocol is sane. In particular, we don't want someone
* adding relative paths (e.g., "../../myuri"), which could be used to move
* outside of /.well-known/ and into space that they control.
*/
IdpProxy.validateProtocol = function(protocol) {
if (!protocol) {
return; // falsy values turn into "default", so they are OK
}
let message = "Invalid protocol for identity provider; ";
if (typeof protocol !== "string") {
throw new Error(message + "must be a string");
}
if (decodeURIComponent(protocol).match(/[\/\\]/)) {
throw new Error(message + "must not include '/' or '\\'");
}
};
IdpProxy.prototype = {
_reset: function() {
this.channel = null;
this.ready = false;
this.counter = 0;
this.tracking = {};
this.pending = [];
},
/**
* Get a sandboxed iframe for hosting the idp-proxy's js. Create a message
* channel down to the frame.
*
* @param errorCallback (function) a callback that will be invoked if there
* is a fatal error starting the proxy
*/
start: function(errorCallback) {
if (this.channel) {
return;
}
let well_known = "https://" + this.domain;
well_known += "/.well-known/idp-proxy/" + this.protocol;
this.channel = new IdpChannel(well_known, this._messageReceived.bind(this));
this.channel.open(function(error) {
if (error) {
this.close();
if (typeof errorCallback === "function") {
errorCallback(error);
}
}
}.bind(this));
},
/**
* Send a message up to the idp proxy. This should be an RTC "SIGN" or
* "VERIFY" message. This method adds the tracking 'id' parameter
* automatically to the message so that the callback is only invoked for the
* response to the message.
*
* The caller is responsible for ensuring that a response is received. If the
* IdP doesn't respond, the callback simply isn't invoked.
*/
send: function(message, callback) {
this.start();
if (this.ready) {
message.id = "" + (++this.counter);
this.tracking[message.id] = callback;
this.channel.send(message);
} else {
this.pending.push({ message: message, callback: callback });
}
},
/**
* Handle a message from the IdP. This automatically sends if the message is
* 'READY' so there is no need to track readiness state outside of this obj.
*/
_messageReceived: function(message) {
if (!message) {
return;
}
if (message.type === "READY") {
this.ready = true;
this.pending.forEach(function(p) {
this.send(p.message, p.callback);
}, this);
this.pending = [];
} else if (this.tracking[message.id]) {
var callback = this.tracking[message.id];
delete this.tracking[message.id];
callback(message);
} else {
let console = Cc["@mozilla.org/consoleservice;1"].
getService(Ci.nsIConsoleService);
console.logStringMessage("Received bad message from IdP: " +
message.id + ":" + message.type);
}
},
/**
* Performs cleanup. The object should be OK to use again.
*/
close: function() {
if (!this.channel) {
return;
}
// dump a message of type "ERROR" in response to all outstanding
// messages to the IdP
let error = { type: "ERROR" };
Object.keys(this.tracking).forEach(function(k) {
this.tracking[k](error);
}, this);
this.pending.forEach(function(p) {
p.callback(error);
}, this);
this.channel.close();
this._reset();
},
toString: function() {
return this.domain + '/' + this.protocol;
}
};
this.IdpProxy = IdpProxy;

View File

@ -1,3 +1,4 @@
/* jshint moz:true, browser:true */
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

View File

@ -40,6 +40,12 @@ EXTRA_COMPONENTS += [
'PeerConnection.manifest',
]
JS_MODULES_PATH = 'modules/media'
EXTRA_JS_MODULES += [
'IdpProxy.jsm',
]
if CONFIG['MOZ_B2G']:
EXPORTS.mozilla += [
'MediaPermissionGonk.h',

View File

@ -7,3 +7,7 @@ ifdef MOZ_WEBRTC_LEAKING_TESTS
MOCHITEST_FILES += \
$(NULL)
endif
INSTALL_TARGETS += idpproxy
idpproxy_FILES := idp.html idp-proxy.js
idpproxy_DEST := $(DEPTH)/_tests/testing/mochitest/.well-known/idp-proxy/

View File

@ -117,7 +117,10 @@ function runTest(aCallback) {
// Running as a Mochitest.
SimpleTest.waitForExplicitFinish();
SpecialPowers.pushPrefEnv({'set': [
['dom.messageChannel.enabled', true],
['media.peerconnection.enabled', true],
['media.peerconnection.identity.enabled', true],
['media.peerconnection.identity.timeout', 1000],
['media.navigator.permission.disabled', true]]
}, function () {
try {

View File

@ -0,0 +1,96 @@
(function(global) {
"use strict";
function IDPJS() {
this.domain = window.location.host;
// so rather than create a million different IdP configurations and litter
// the world with files all containing near-identical code, let's use the
// hash/URL fragment as a way of generating instructions for the IdP
this.instructions = window.location.hash.replace("#", "").split(":");
this.port = window.rtcwebIdentityPort;
this.port.onmessage = this.receiveMessage.bind(this);
this.sendResponse({
type : "READY"
});
}
IDPJS.prototype.getDelay = function() {
// instructions in the form "delay123" have that many milliseconds
// added before sending the response
var delay = 0;
function addDelay(instruction) {
var m = instruction.match(/^delay(\d+)$/);
if (m) {
delay += parseInt(m[1], 10);
}
}
this.instructions.forEach(addDelay);
return delay;
};
function is(target) {
return function(instruction) {
return instruction === target;
};
}
IDPJS.prototype.sendResponse = function(response) {
// we don't touch the READY message unless told to
if (response.type === "READY" && !this.instructions.some(is("ready"))) {
this.port.postMessage(response);
return;
}
// if any instruction is "error", return an error.
if (this.instructions.some(is("error"))) {
response.type = "ERROR";
}
window.setTimeout(function() {
this.port.postMessage(response);
}.bind(this), this.getDelay());
};
IDPJS.prototype.receiveMessage = function(ev) {
var message = ev.data;
switch (message.type) {
case "SIGN":
this.sendResponse({
type : "SUCCESS",
id : message.id,
message : {
idp : {
domain : this.domain,
protocol : "idp.html"
},
assertion : JSON.stringify({
identity : "someone@" + this.domain,
contents : message.message
})
}
});
break;
case "VERIFY":
this.sendResponse({
type : "SUCCESS",
id : message.id,
message : {
identity : {
name : "someone@" + this.domain,
displayname : "Someone"
},
contents : JSON.parse(message.message).contents
}
});
break;
default:
this.sendResponse({
type : "ERROR",
error : JSON.stringify(message)
});
break;
}
};
global.idp = new IDPJS();
}(this));

View File

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>IDP Proxy</title>
<script src="idp-proxy.js"></script>
</head>
<body>
Test IDP Proxy
</body>
</html>

View File

@ -52,3 +52,4 @@ skip-if = os == 'mac'
[test_peerConnection_setRemoteOfferInHaveLocalOffer.html]
[test_peerConnection_throwInCallbacks.html]
[test_peerConnection_toJSON.html]
[test_idpproxy.html]

View File

@ -0,0 +1,122 @@
<html>
<head>
<meta charset="utf-8" />
<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
<script type="application/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
</head>
<body>
<script class="testbody" type="application/javascript">
"use strict";
var Cu = SpecialPowers.Cu;
var rtcid = Cu.import("resource://gre/modules/media/IdpProxy.jsm");
var IdpProxy = rtcid.IdpProxy;
var request = {
type: "SIGN",
message: "foo"
};
function test_domain_sandbox(done) {
var diabolical = {
toString : function() {
return "example.com/path";
}
};
var domains = [ "ex/foo", "user@ex", "user:pass@ex", "ex#foo", "ex?foo",
"", 12, null, diabolical, true ];
domains.forEach(function(domain) {
try {
var idp = new IdpProxy(domain);
ok(false, "IdpProxy didn't catch bad domain: " + domain);
} catch (e) {
var str = (typeof domain === "string") ? domain : typeof domain;
ok(true, "Evil domain '" + str + "' raises exception");
}
});
done();
}
function test_protocol_sandbox(done) {
var protos = [ "../evil/proto", "..%2Fevil%2Fproto",
"\\evil", "%5cevil", 12, true, {} ];
protos.forEach(function(proto) {
try {
var idp = new IdpProxy("example.com", proto);
ok(false, "IdpProxy didn't catch bad protocol: " + proto);
} catch (e) {
var str = (typeof proto === "string") ? proto : typeof proto;
ok(true, "Evil protocol '" + proto + "' raises exception");
}
});
done();
}
function handleFailure(done) {
return function failure(error) {
ok(false, "IdP error" + error);
done();
};
}
function test_success_response(done) {
var idp;
function handleResponse(response) {
is(SpecialPowers.wrap(response).type, "SUCCESS", "IdP responds with SUCCESS");
idp.close();
done();
}
idp = new IdpProxy("example.com", "idp.html");
idp.start(handleFailure(done));
idp.send(request, handleResponse);
}
function test_error_response(done) {
var idp;
function handleResponse(response) {
is(SpecialPowers.wrap(response).type, "ERROR", "IdP should produce ERROR");
idp.close();
done();
}
idp = new IdpProxy("example.com", "idp.html#error");
idp.start(handleFailure(done));
idp.send(request, handleResponse);
}
function test_delayed_response(done) {
var idp;
function handleResponse(response) {
is(SpecialPowers.wrap(response).type, "SUCCESS",
"IdP should handle delayed response");
idp.close();
done();
}
idp = new IdpProxy("example.com", "idp.html#delay100");
idp.start(handleFailure(done));
idp.send(request, handleResponse);
}
var TESTS = [ test_domain_sandbox, test_protocol_sandbox,
test_success_response, test_error_response,
test_delayed_response ];
function run_next_test() {
if (TESTS.length) {
var test = TESTS.shift();
test(run_next_test);
} else {
SimpleTest.finish();
}
}
SimpleTest.waitForExplicitFinish();
SpecialPowers.pushPrefEnv({
"set" : [ [ "dom.messageChannel.enabled", true ] ]
}, run_next_test);
</script>
</body>
</html>

View File

@ -1,6 +0,0 @@
# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
# vim: set filetype=python:
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.

View File

@ -4,6 +4,6 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
DIRS += ['chrome', 'mochitest']
DIRS += ['chrome']
XPCSHELL_TESTS_MANIFESTS += ['unit/xpcshell.ini']