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