Commit 041a6dff authored by Randall Leeds's avatar Randall Leeds

Refactor WebSocket origin security

Rather than using the cross site request forgery token in the URL
for the WebSocket, check the HTTP Origin header. All spec-compliant
user agents send a proper Origin header so this is sufficient to
protect users from malicious cross-site access to the WebSocket.

As a consequence, the front-end code to bootstrap the streamer can
be simplified. The streamer no longer has any provider. Its URL and
transport are passed explicitly to the ``open`` method.

While I was here, I added support for the ``protocols`` argument to
the ``open`` method, added support for the ``onopen`` and ``onclose``
handlers, set the client identifier on the ``$http`` service default
headers, aligned the state constants with the standard ones, and
ensured that the socket cannot be closed twice.
parent 94cfff2b
...@@ -57,12 +57,6 @@ configure = [ ...@@ -57,12 +57,6 @@ configure = [
# TODO: move all front-end templates into their own directory for safety # TODO: move all front-end templates into their own directory for safety
basePattern = baseURI.replace /\/[^\/]*$/, '/**.html' basePattern = baseURI.replace /\/[^\/]*$/, '/**.html'
$sceDelegateProvider.resourceUrlWhitelist ['self', basePattern] $sceDelegateProvider.resourceUrlWhitelist ['self', basePattern]
streamerProvider.urlFn = ['xsrf', (xsrf) ->
base = baseURI.replace(/^http/, 'ws')
"#{base}ws?csrf_token=#{xsrf.token}"
]
] ]
angular.module('h', imports, configure) angular.module('h', imports, configure)
class AppController class AppController
this.$inject = [ this.$inject = [
'$location', '$route', '$scope', '$timeout', '$location', '$route', '$scope', '$timeout', '$window',
'annotator', 'auth', 'documentHelpers', 'drafts', 'flash', 'identity', 'annotator', 'auth', 'documentHelpers', 'drafts', 'flash', 'identity',
'permissions', 'streamer', 'streamfilter' 'permissions', 'streamer', 'streamfilter'
] ]
constructor: ( constructor: (
$location, $route, $scope, $timeout, $location, $route, $scope, $timeout, $window,
annotator, auth, documentHelpers, drafts, flash, identity, annotator, auth, documentHelpers, drafts, flash, identity,
permissions, streamer, streamfilter, permissions, streamer, streamfilter,
...@@ -15,6 +14,7 @@ class AppController ...@@ -15,6 +14,7 @@ class AppController
$scope.auth = auth $scope.auth = auth
isFirstRun = $location.search().hasOwnProperty('firstrun') isFirstRun = $location.search().hasOwnProperty('firstrun')
streamerUrl = documentHelpers.baseURI.replace(/^http/, 'ws') + 'ws'
applyUpdates = (action, data) -> applyUpdates = (action, data) ->
# Update the application with new data from the websocket. # Update the application with new data from the websocket.
...@@ -110,8 +110,9 @@ class AppController ...@@ -110,8 +110,9 @@ class AppController
# Reload services # Reload services
initStore() initStore()
streamer.close() streamer.close()
streamer.open() streamer.open($window.WebSocket, streamerUrl)
$scope.$watch 'socialView.name', (newValue, oldValue) -> $scope.$watch 'socialView.name', (newValue, oldValue) ->
return if newValue is oldValue return if newValue is oldValue
......
ST_CLOSED = 1 ST_CONNECTING = 0
ST_CONNECTING = 2 ST_OPEN = 1
ST_OPEN = 3 ST_CLOSING = 2
ST_CLOSED = 3
###* ###*
# @ngdoc service # @ngdoc service
# @name Streamer # @name Streamer
# #
# @param {String} urlFn A function that will be called with injections to # @property {string} clientId A unique identifier for this client.
# generate the socket URL.
# #
# @description # @description
# Provides access to the streamer websocket. # Provides access to the streamer websocket.
### ###
class Streamer class Streamer
constructor: (transport, urlFn) -> constructor: ->
this.onmessage = -> this.clientId = null
this.onopen = angular.noop
this.onclose = angular.noop
this.onmessage = angular.noop
this._failCount = 0 this._failCount = 0
this._queue = [] this._queue = []
this._state = ST_CLOSED this._state = ST_CLOSED
this._transport = transport
this._urlFn = urlFn
###* ###*
# @ngdoc method # @ngdoc method
# @name Streamer#open # @name Streamer#open
# #
# @param {Object} transport The transport class to create.
# @param {string} url The URL to which to connect.
# @param {Object|Array} protocols The protocol (or protocols) to use.
#
# @description # @description
# Open the streamer websocket if it is not already open or connecting. Handles # Open the streamer websocket if it is not already open or connecting. Handles
# connection failures and sets up onmessage handlers. # connection failures and sets up onmessage handlers.
### ###
open: -> open: (transport, url, protocols) ->
if this._state == ST_OPEN || this._state == ST_CONNECTING if this._state == ST_OPEN || this._state == ST_CONNECTING
return return
self = this self = this
this._sock = new this._transport(this._urlFn())
if protocols
this._sock = new transport(url, protocols)
else
this._sock = new transport(url)
this._state = ST_CONNECTING this._state = ST_CONNECTING
this._sock.onopen = -> this._sock.onopen = ->
self._state = ST_OPEN self._state = ST_OPEN
self._failCount = 0 self._failCount = 0
clientId = uuid.v4() # Send the client id
setAjaxClientId(clientId) if self.clientId
self.send(messageType: 'client_id', value: self.clientId)
# Give the application a chance to initialize the connection
self.onopen(name: 'open')
# Generate and send our client ID
self.send({
messageType: 'client_id'
value: clientId
})
# Process queued messages # Process queued messages
self._sendQueue() self._sendQueue()
...@@ -57,7 +67,9 @@ class Streamer ...@@ -57,7 +67,9 @@ class Streamer
self._state = ST_CLOSED self._state = ST_CLOSED
self._failCount++ self._failCount++
setTimeout((-> self.open()), backoff(self._failCount, 10)) reconnect = angular.bind(self, self.open, transport, url, protocols)
waitFor = backoff(self._failCount, 10)
setTimeout(reconnect, waitFor)
this._sock.onmessage = (msg) -> this._sock.onmessage = (msg) ->
self.onmessage(JSON.parse(msg.data)) self.onmessage(JSON.parse(msg.data))
...@@ -70,13 +82,18 @@ class Streamer ...@@ -70,13 +82,18 @@ class Streamer
# Close the streamer socket. # Close the streamer socket.
### ###
close: -> close: ->
if this._state == ST_CLOSED if this._state == ST_CLOSING or this._state == ST_CLOSED
return return
self = this
this._sock.onclose = -> this._sock.onclose = ->
self._state = ST_CLOSED
self.onclose()
this._sock.close() this._sock.close()
this._sock = null this._sock = null
this._state = ST_CLOSED this._state = ST_CLOSING
###* ###*
# @ngdoc method # @ngdoc method
...@@ -104,22 +121,16 @@ backoff = (index, max) -> ...@@ -104,22 +121,16 @@ backoff = (index, max) ->
index = Math.min(index, max) index = Math.min(index, max)
return 500 * Math.random() * (Math.pow(2, index) - 1) return 500 * Math.random() * (Math.pow(2, index) - 1)
setAjaxClientId = (clientId) ->
$.ajaxSetup({
headers: {
'X-Client-Id': clientId
}
})
streamerProvider = ->
provider = {}
provider.urlFn = null
provider.$get = ['$injector', '$window', ($injector, $window) ->
urlFn = angular.bind $injector, $injector.invoke, provider.urlFn
new Streamer($window.WebSocket, urlFn)
]
return provider
run = [
'$http', '$window', 'streamer'
($http, $window, streamer) ->
clientId = uuid.v4()
streamer.clientId = clientId
$.ajaxSetup(headers: {'X-Client-Id': clientId})
$http.defaults.headers.common['X-Client-Id'] = clientId
]
angular.module('h.streamer', []) angular.module('h.streamer', [])
.provider('streamer', streamerProvider) .service('streamer', Streamer)
.run(run)
...@@ -6,19 +6,16 @@ describe 'streamer', -> ...@@ -6,19 +6,16 @@ describe 'streamer', ->
WebSocket = null WebSocket = null
fakeSock = null fakeSock = null
streamer = null streamer = null
url = 'wss://magicstreemz/giraffe'
beforeEach module('h.streamer') beforeEach module('h.streamer')
beforeEach module ($provide, streamerProvider) -> beforeEach module ->
fakeSock = { fakeSock = {
send: sandbox.spy() send: sandbox.spy()
close: sandbox.spy() close: sandbox.spy()
} }
WebSocket = sandbox.stub().returns(fakeSock) WebSocket = sandbox.stub().returns(fakeSock)
$provide.decorator '$window', ($delegate) ->
angular.extend $delegate, {WebSocket}
$provide.value 'webSocketUrl', 'wss://magicstreemz/giraffe'
streamerProvider.urlFn = (webSocketUrl) -> webSocketUrl
return return
beforeEach inject (_streamer_) -> beforeEach inject (_streamer_) ->
...@@ -28,22 +25,35 @@ describe 'streamer', -> ...@@ -28,22 +25,35 @@ describe 'streamer', ->
sandbox.restore() sandbox.restore()
it 'calls the transport function with the new keyword', -> it 'calls the transport function with the new keyword', ->
streamer.open() streamer.open(WebSocket, url)
assert.calledWithNew(WebSocket) assert.calledWithNew(WebSocket)
it 'creates a socket with the correct base URL', -> it 'creates a socket with the correct base URL', ->
streamer.open() streamer.open(WebSocket, url)
assert.calledWith(WebSocket, 'wss://magicstreemz/giraffe') assert.calledWith(WebSocket, 'wss://magicstreemz/giraffe')
it 'does not open another socket while a socket is connecting', -> it 'does not open another socket while connecting or connected', ->
streamer.open() streamer.open(WebSocket, url)
streamer.open() streamer.open(WebSocket, url)
assert.calledOnce(WebSocket)
fakeSock.onopen()
streamer.open(WebSocket, url)
assert.calledOnce(WebSocket) assert.calledOnce(WebSocket)
it 'queues messages until the socket is open', -> it 'does not close the socket again when already closing', ->
streamer.open() streamer.open(WebSocket, url)
streamer.close()
streamer.close()
assert.calledOnce(fakeSock.close)
it 'queues messages kuntil the socket is open', ->
streamer.open(WebSocket, url)
streamer.send({animal: 'elephant'}) streamer.send({animal: 'elephant'})
assert.notCalled(fakeSock.send) assert.notCalled(fakeSock.send)
...@@ -52,8 +62,18 @@ describe 'streamer', -> ...@@ -52,8 +62,18 @@ describe 'streamer', ->
assert.called(fakeSock.send) assert.called(fakeSock.send)
it 'calls the onopen handler once the socket is open', ->
streamer.onopen = sinon.spy()
streamer.open(WebSocket, url)
assert.notCalled(streamer.onopen)
fakeSock.onopen()
assert.called(streamer.onopen)
it 'preserves message ordering in the queue', -> it 'preserves message ordering in the queue', ->
streamer.open() streamer.open(WebSocket, url)
streamer.send({animal: 'elephant'}) streamer.send({animal: 'elephant'})
streamer.send({animal: 'giraffe'}) streamer.send({animal: 'giraffe'})
fakeSock.onopen() fakeSock.onopen()
...@@ -64,7 +84,7 @@ describe 'streamer', -> ...@@ -64,7 +84,7 @@ describe 'streamer', ->
assert.equal(secondAnimal, 'giraffe') assert.equal(secondAnimal, 'giraffe')
it 'converts message data to JSON', -> it 'converts message data to JSON', ->
streamer.open() streamer.open(WebSocket, url)
streamer.send({animal: 'badger'}) streamer.send({animal: 'badger'})
fakeSock.onopen() fakeSock.onopen()
...@@ -72,7 +92,7 @@ describe 'streamer', -> ...@@ -72,7 +92,7 @@ describe 'streamer', ->
it 'sends a client ID as the first message once the socket opens', -> it 'sends a client ID as the first message once the socket opens', ->
streamer.send({animal: 'elephant'}) streamer.send({animal: 'elephant'})
streamer.open() streamer.open(WebSocket, url)
fakeSock.onopen() fakeSock.onopen()
msg = fakeSock.send.getCall(0).args[0] msg = fakeSock.send.getCall(0).args[0]
...@@ -83,21 +103,22 @@ describe 'streamer', -> ...@@ -83,21 +103,22 @@ describe 'streamer', ->
it 'attempts to reopen the socket on connection failure', -> it 'attempts to reopen the socket on connection failure', ->
clock = sandbox.useFakeTimers() clock = sandbox.useFakeTimers()
streamer.open() streamer.open(WebSocket, url)
fakeSock.onclose() fakeSock.onclose()
clock.tick(500) clock.tick(500)
assert.calledTwice(WebSocket) assert.calledTwice(WebSocket)
assert.match(WebSocket.getCall(0).args, WebSocket.getCall(1).args)
it 'closes the socket when close is called', -> it 'closes the socket when close is called', ->
streamer.open() streamer.open(WebSocket, url)
streamer.close() streamer.close()
assert.calledOnce(fakeSock.close) assert.calledOnce(fakeSock.close)
it 'only closes the socket once', -> it 'only closes the socket once', ->
streamer.open() streamer.open(WebSocket, url)
streamer.close() streamer.close()
streamer.close() streamer.close()
...@@ -105,7 +126,7 @@ describe 'streamer', -> ...@@ -105,7 +126,7 @@ describe 'streamer', ->
it 'does not try and reopen the socket when closed explicitly', -> it 'does not try and reopen the socket when closed explicitly', ->
clock = sandbox.useFakeTimers() clock = sandbox.useFakeTimers()
streamer.open() streamer.open(WebSocket, url)
streamer.close() streamer.close()
fakeSock.onclose() fakeSock.onclose()
...@@ -114,12 +135,12 @@ describe 'streamer', -> ...@@ -114,12 +135,12 @@ describe 'streamer', ->
it 'calls the onmessage handler when the socket receives a message', -> it 'calls the onmessage handler when the socket receives a message', ->
streamer.onmessage = sinon.spy() streamer.onmessage = sinon.spy()
streamer.open() streamer.open(WebSocket, url)
fakeSock.onmessage(data: JSON.stringify({animal: 'baboon'})) fakeSock.onmessage(data: JSON.stringify({animal: 'baboon'}))
assert.called(streamer.onmessage) assert.called(streamer.onmessage)
it 'calls the onmessage handler with parsed JSON', -> it 'calls the onmessage handler with parsed JSON', ->
streamer.onmessage = sinon.spy() streamer.onmessage = sinon.spy()
streamer.open() streamer.open(WebSocket, url)
fakeSock.onmessage(data: JSON.stringify({animal: 'baboon'})) fakeSock.onmessage(data: JSON.stringify({animal: 'baboon'}))
assert.calledWith(streamer.onmessage, {animal: 'baboon'}) assert.calledWith(streamer.onmessage, {animal: 'baboon'})
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment