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