##// END OF EJS Templates
Merge pull request #6480 from minrk/origin-check-no-proto...
Min RK -
r17898:f1b046e7 merge
parent child Browse files
Show More
@@ -1,205 +1,214 b''
1 1 """Tornado handlers for WebSocket <-> ZMQ sockets."""
2 2
3 3 # Copyright (c) IPython Development Team.
4 4 # Distributed under the terms of the Modified BSD License.
5 5
6 6 import json
7 7
8 8 try:
9 9 from urllib.parse import urlparse # Py 3
10 10 except ImportError:
11 11 from urlparse import urlparse # Py 2
12 12
13 13 try:
14 14 from http.cookies import SimpleCookie # Py 3
15 15 except ImportError:
16 16 from Cookie import SimpleCookie # Py 2
17 17 import logging
18 18
19 19 import tornado
20 20 from tornado import ioloop
21 21 from tornado import web
22 22 from tornado import websocket
23 23
24 24 from IPython.kernel.zmq.session import Session
25 25 from IPython.utils.jsonutil import date_default
26 26 from IPython.utils.py3compat import PY3, cast_unicode
27 27
28 28 from .handlers import IPythonHandler
29 29
30 30
31 31 class ZMQStreamHandler(websocket.WebSocketHandler):
32 32
33 33 def check_origin(self, origin):
34 34 """Check Origin == Host or Access-Control-Allow-Origin.
35 35
36 36 Tornado >= 4 calls this method automatically, raising 403 if it returns False.
37 37 We call it explicitly in `open` on Tornado < 4.
38 38 """
39 39 if self.allow_origin == '*':
40 40 return True
41 41
42 42 host = self.request.headers.get("Host")
43 43
44 44 # If no header is provided, assume we can't verify origin
45 if(origin is None or host is None):
45 if origin is None:
46 self.log.warn("Missing Origin header, rejecting WebSocket connection.")
47 return False
48 if host is None:
49 self.log.warn("Missing Host header, rejecting WebSocket connection.")
46 50 return False
47 51
48 host_origin = "{0}://{1}".format(self.request.protocol, host)
52 origin = origin.lower()
53 origin_host = urlparse(origin).netloc
49 54
50 55 # OK if origin matches host
51 if origin == host_origin:
56 if origin_host == host:
52 57 return True
53 58
54 59 # Check CORS headers
55 60 if self.allow_origin:
56 return self.allow_origin == origin
61 allow = self.allow_origin == origin
57 62 elif self.allow_origin_pat:
58 return bool(self.allow_origin_pat.match(origin))
63 allow = bool(self.allow_origin_pat.match(origin))
59 64 else:
60 65 # No CORS headers deny the request
61 return False
66 allow = False
67 if not allow:
68 self.log.warn("Blocking Cross Origin WebSocket Attempt. Origin: %s, Host: %s",
69 origin, host,
70 )
71 return allow
62 72
63 73 def clear_cookie(self, *args, **kwargs):
64 74 """meaningless for websockets"""
65 75 pass
66 76
67 77 def _reserialize_reply(self, msg_list):
68 78 """Reserialize a reply message using JSON.
69 79
70 80 This takes the msg list from the ZMQ socket, unserializes it using
71 81 self.session and then serializes the result using JSON. This method
72 82 should be used by self._on_zmq_reply to build messages that can
73 83 be sent back to the browser.
74 84 """
75 85 idents, msg_list = self.session.feed_identities(msg_list)
76 86 msg = self.session.unserialize(msg_list)
77 87 try:
78 88 msg['header'].pop('date')
79 89 except KeyError:
80 90 pass
81 91 try:
82 92 msg['parent_header'].pop('date')
83 93 except KeyError:
84 94 pass
85 95 msg.pop('buffers')
86 96 return json.dumps(msg, default=date_default)
87 97
88 98 def _on_zmq_reply(self, msg_list):
89 99 # Sometimes this gets triggered when the on_close method is scheduled in the
90 100 # eventloop but hasn't been called.
91 101 if self.stream.closed(): return
92 102 try:
93 103 msg = self._reserialize_reply(msg_list)
94 104 except Exception:
95 105 self.log.critical("Malformed message: %r" % msg_list, exc_info=True)
96 106 else:
97 107 self.write_message(msg)
98 108
99 109 def allow_draft76(self):
100 110 """Allow draft 76, until browsers such as Safari update to RFC 6455.
101 111
102 112 This has been disabled by default in tornado in release 2.2.0, and
103 113 support will be removed in later versions.
104 114 """
105 115 return True
106 116
107 117 # ping interval for keeping websockets alive (30 seconds)
108 118 WS_PING_INTERVAL = 30000
109 119
110 120 class AuthenticatedZMQStreamHandler(ZMQStreamHandler, IPythonHandler):
111 121 ping_callback = None
112 122 last_ping = 0
113 123 last_pong = 0
114 124
115 125 @property
116 126 def ping_interval(self):
117 127 """The interval for websocket keep-alive pings.
118 128
119 129 Set ws_ping_interval = 0 to disable pings.
120 130 """
121 131 return self.settings.get('ws_ping_interval', WS_PING_INTERVAL)
122 132
123 133 @property
124 134 def ping_timeout(self):
125 135 """If no ping is received in this many milliseconds,
126 136 close the websocket connection (VPNs, etc. can fail to cleanly close ws connections).
127 137 Default is max of 3 pings or 30 seconds.
128 138 """
129 139 return self.settings.get('ws_ping_timeout',
130 140 max(3 * self.ping_interval, WS_PING_INTERVAL)
131 141 )
132 142
133 143 def set_default_headers(self):
134 144 """Undo the set_default_headers in IPythonHandler
135 145
136 146 which doesn't make sense for websockets
137 147 """
138 148 pass
139 149
140 150 def open(self, kernel_id):
141 151 self.kernel_id = cast_unicode(kernel_id, 'ascii')
142 152 # Check to see that origin matches host directly, including ports
143 153 # Tornado 4 already does CORS checking
144 154 if tornado.version_info[0] < 4:
145 155 if not self.check_origin(self.get_origin()):
146 self.log.warn("Cross Origin WebSocket Attempt from %s", self.get_origin())
147 156 raise web.HTTPError(403)
148 157
149 158 self.session = Session(config=self.config)
150 159 self.save_on_message = self.on_message
151 160 self.on_message = self.on_first_message
152 161
153 162 # start the pinging
154 163 if self.ping_interval > 0:
155 164 self.last_ping = ioloop.IOLoop.instance().time() # Remember time of last ping
156 165 self.last_pong = self.last_ping
157 166 self.ping_callback = ioloop.PeriodicCallback(self.send_ping, self.ping_interval)
158 167 self.ping_callback.start()
159 168
160 169 def send_ping(self):
161 170 """send a ping to keep the websocket alive"""
162 171 if self.stream.closed() and self.ping_callback is not None:
163 172 self.ping_callback.stop()
164 173 return
165 174
166 175 # check for timeout on pong. Make sure that we really have sent a recent ping in
167 176 # case the machine with both server and client has been suspended since the last ping.
168 177 now = ioloop.IOLoop.instance().time()
169 178 since_last_pong = 1e3 * (now - self.last_pong)
170 179 since_last_ping = 1e3 * (now - self.last_ping)
171 180 if since_last_ping < 2*self.ping_interval and since_last_pong > self.ping_timeout:
172 181 self.log.warn("WebSocket ping timeout after %i ms.", since_last_pong)
173 182 self.close()
174 183 return
175 184
176 185 self.ping(b'')
177 186 self.last_ping = now
178 187
179 188 def on_pong(self, data):
180 189 self.last_pong = ioloop.IOLoop.instance().time()
181 190
182 191 def _inject_cookie_message(self, msg):
183 192 """Inject the first message, which is the document cookie,
184 193 for authentication."""
185 194 if not PY3 and isinstance(msg, unicode):
186 195 # Cookie constructor doesn't accept unicode strings
187 196 # under Python 2.x for some reason
188 197 msg = msg.encode('utf8', 'replace')
189 198 try:
190 199 identity, msg = msg.split(':', 1)
191 200 self.session.session = cast_unicode(identity, 'ascii')
192 201 except Exception:
193 202 logging.error("First ws message didn't have the form 'identity:[cookie]' - %r", msg)
194 203
195 204 try:
196 205 self.request._cookies = SimpleCookie(msg)
197 206 except:
198 207 self.log.warn("couldn't parse cookie string: %s",msg, exc_info=True)
199 208
200 209 def on_first_message(self, msg):
201 210 self._inject_cookie_message(msg)
202 211 if self.get_current_user() is None:
203 212 self.log.warn("Couldn't authenticate WebSocket connection")
204 213 raise web.HTTPError(403)
205 214 self.on_message = self.save_on_message
General Comments 0
You need to be logged in to leave comments. Login now