##// END OF EJS Templates
Log CSP violations via report
Kyle Kelley -
Show More
@@ -1,507 +1,521 b''
1 1 """Base Tornado handlers for the notebook server."""
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 functools
7 7 import json
8 8 import logging
9 9 import os
10 10 import re
11 11 import sys
12 12 import traceback
13 13 try:
14 14 # py3
15 15 from http.client import responses
16 16 except ImportError:
17 17 from httplib import responses
18 18
19 19 from jinja2 import TemplateNotFound
20 20 from tornado import web
21 21
22 22 try:
23 23 from tornado.log import app_log
24 24 except ImportError:
25 25 app_log = logging.getLogger()
26 26
27 27 import IPython
28 28 from IPython.utils.sysinfo import get_sys_info
29 29
30 30 from IPython.config import Application
31 31 from IPython.utils.path import filefind
32 32 from IPython.utils.py3compat import string_types
33 33 from IPython.html.utils import is_hidden, url_path_join, url_escape
34 34
35 from IPython.html.services.security.handlers import csp_report_uri
36
35 37 #-----------------------------------------------------------------------------
36 38 # Top-level handlers
37 39 #-----------------------------------------------------------------------------
38 40 non_alphanum = re.compile(r'[^A-Za-z0-9]')
39 41
40 42 sys_info = json.dumps(get_sys_info())
41 43
42 44 class AuthenticatedHandler(web.RequestHandler):
43 45 """A RequestHandler with an authenticated user."""
44 46
45 47 def set_default_headers(self):
46 48 headers = self.settings.get('headers', {})
47 49
48 50 if "Content-Security-Policy" not in headers:
49 headers["Content-Security-Policy"] = "default-src 'self'"
51 #headers["Content-Security-Policy"] = ""
52 pass
53
54 if "Content-Security-Policy-Report-Only" not in headers:
55
56 reporter_policy = ("default-src 'self'; " +
57 "report-uri " + url_path_join(self.base_url, csp_report_uri) +
58 ";"
59 )
60 self.log.info(reporter_policy)
61
62 headers["Content-Security-Policy-Report-Only"] = reporter_policy
50 63
64 # Allow for overriding headers
51 65 for header_name,value in headers.items() :
52 66 try:
53 67 self.set_header(header_name, value)
54 68 except Exception:
55 69 # tornado raise Exception (not a subclass)
56 70 # if method is unsupported (websocket and Access-Control-Allow-Origin
57 71 # for example, so just ignore)
58 72 pass
59 73
60 74 def clear_login_cookie(self):
61 75 self.clear_cookie(self.cookie_name)
62 76
63 77 def get_current_user(self):
64 78 user_id = self.get_secure_cookie(self.cookie_name)
65 79 # For now the user_id should not return empty, but it could eventually
66 80 if user_id == '':
67 81 user_id = 'anonymous'
68 82 if user_id is None:
69 83 # prevent extra Invalid cookie sig warnings:
70 84 self.clear_login_cookie()
71 85 if not self.login_available:
72 86 user_id = 'anonymous'
73 87 return user_id
74 88
75 89 @property
76 90 def cookie_name(self):
77 91 default_cookie_name = non_alphanum.sub('-', 'username-{}'.format(
78 92 self.request.host
79 93 ))
80 94 return self.settings.get('cookie_name', default_cookie_name)
81 95
82 96 @property
83 97 def password(self):
84 98 """our password"""
85 99 return self.settings.get('password', '')
86 100
87 101 @property
88 102 def logged_in(self):
89 103 """Is a user currently logged in?
90 104
91 105 """
92 106 user = self.get_current_user()
93 107 return (user and not user == 'anonymous')
94 108
95 109 @property
96 110 def login_available(self):
97 111 """May a user proceed to log in?
98 112
99 113 This returns True if login capability is available, irrespective of
100 114 whether the user is already logged in or not.
101 115
102 116 """
103 117 return bool(self.settings.get('password', ''))
104 118
105 119
106 120 class IPythonHandler(AuthenticatedHandler):
107 121 """IPython-specific extensions to authenticated handling
108 122
109 123 Mostly property shortcuts to IPython-specific settings.
110 124 """
111 125
112 126 @property
113 127 def config(self):
114 128 return self.settings.get('config', None)
115 129
116 130 @property
117 131 def log(self):
118 132 """use the IPython log by default, falling back on tornado's logger"""
119 133 if Application.initialized():
120 134 return Application.instance().log
121 135 else:
122 136 return app_log
123 137
124 138 #---------------------------------------------------------------
125 139 # URLs
126 140 #---------------------------------------------------------------
127 141
128 142 @property
129 143 def version_hash(self):
130 144 """The version hash to use for cache hints for static files"""
131 145 return self.settings.get('version_hash', '')
132 146
133 147 @property
134 148 def mathjax_url(self):
135 149 return self.settings.get('mathjax_url', '')
136 150
137 151 @property
138 152 def base_url(self):
139 153 return self.settings.get('base_url', '/')
140 154
141 155 @property
142 156 def ws_url(self):
143 157 return self.settings.get('websocket_url', '')
144 158
145 159 @property
146 160 def contents_js_source(self):
147 161 self.log.debug("Using contents: %s", self.settings.get('contents_js_source',
148 162 'services/contents'))
149 163 return self.settings.get('contents_js_source', 'services/contents')
150 164
151 165 #---------------------------------------------------------------
152 166 # Manager objects
153 167 #---------------------------------------------------------------
154 168
155 169 @property
156 170 def kernel_manager(self):
157 171 return self.settings['kernel_manager']
158 172
159 173 @property
160 174 def contents_manager(self):
161 175 return self.settings['contents_manager']
162 176
163 177 @property
164 178 def cluster_manager(self):
165 179 return self.settings['cluster_manager']
166 180
167 181 @property
168 182 def session_manager(self):
169 183 return self.settings['session_manager']
170 184
171 185 @property
172 186 def terminal_manager(self):
173 187 return self.settings['terminal_manager']
174 188
175 189 @property
176 190 def kernel_spec_manager(self):
177 191 return self.settings['kernel_spec_manager']
178 192
179 193 @property
180 194 def config_manager(self):
181 195 return self.settings['config_manager']
182 196
183 197 #---------------------------------------------------------------
184 198 # CORS
185 199 #---------------------------------------------------------------
186 200
187 201 @property
188 202 def allow_origin(self):
189 203 """Normal Access-Control-Allow-Origin"""
190 204 return self.settings.get('allow_origin', '')
191 205
192 206 @property
193 207 def allow_origin_pat(self):
194 208 """Regular expression version of allow_origin"""
195 209 return self.settings.get('allow_origin_pat', None)
196 210
197 211 @property
198 212 def allow_credentials(self):
199 213 """Whether to set Access-Control-Allow-Credentials"""
200 214 return self.settings.get('allow_credentials', False)
201 215
202 216 def set_default_headers(self):
203 217 """Add CORS headers, if defined"""
204 218 super(IPythonHandler, self).set_default_headers()
205 219 if self.allow_origin:
206 220 self.set_header("Access-Control-Allow-Origin", self.allow_origin)
207 221 elif self.allow_origin_pat:
208 222 origin = self.get_origin()
209 223 if origin and self.allow_origin_pat.match(origin):
210 224 self.set_header("Access-Control-Allow-Origin", origin)
211 225 if self.allow_credentials:
212 226 self.set_header("Access-Control-Allow-Credentials", 'true')
213 227
214 228 def get_origin(self):
215 229 # Handle WebSocket Origin naming convention differences
216 230 # The difference between version 8 and 13 is that in 8 the
217 231 # client sends a "Sec-Websocket-Origin" header and in 13 it's
218 232 # simply "Origin".
219 233 if "Origin" in self.request.headers:
220 234 origin = self.request.headers.get("Origin")
221 235 else:
222 236 origin = self.request.headers.get("Sec-Websocket-Origin", None)
223 237 return origin
224 238
225 239 #---------------------------------------------------------------
226 240 # template rendering
227 241 #---------------------------------------------------------------
228 242
229 243 def get_template(self, name):
230 244 """Return the jinja template object for a given name"""
231 245 return self.settings['jinja2_env'].get_template(name)
232 246
233 247 def render_template(self, name, **ns):
234 248 ns.update(self.template_namespace)
235 249 template = self.get_template(name)
236 250 return template.render(**ns)
237 251
238 252 @property
239 253 def template_namespace(self):
240 254 return dict(
241 255 base_url=self.base_url,
242 256 ws_url=self.ws_url,
243 257 logged_in=self.logged_in,
244 258 login_available=self.login_available,
245 259 static_url=self.static_url,
246 260 sys_info=sys_info,
247 261 contents_js_source=self.contents_js_source,
248 262 version_hash=self.version_hash,
249 263 )
250 264
251 265 def get_json_body(self):
252 266 """Return the body of the request as JSON data."""
253 267 if not self.request.body:
254 268 return None
255 269 # Do we need to call body.decode('utf-8') here?
256 270 body = self.request.body.strip().decode(u'utf-8')
257 271 try:
258 272 model = json.loads(body)
259 273 except Exception:
260 274 self.log.debug("Bad JSON: %r", body)
261 275 self.log.error("Couldn't parse JSON", exc_info=True)
262 276 raise web.HTTPError(400, u'Invalid JSON in body of request')
263 277 return model
264 278
265 279 def write_error(self, status_code, **kwargs):
266 280 """render custom error pages"""
267 281 exc_info = kwargs.get('exc_info')
268 282 message = ''
269 283 status_message = responses.get(status_code, 'Unknown HTTP Error')
270 284 if exc_info:
271 285 exception = exc_info[1]
272 286 # get the custom message, if defined
273 287 try:
274 288 message = exception.log_message % exception.args
275 289 except Exception:
276 290 pass
277 291
278 292 # construct the custom reason, if defined
279 293 reason = getattr(exception, 'reason', '')
280 294 if reason:
281 295 status_message = reason
282 296
283 297 # build template namespace
284 298 ns = dict(
285 299 status_code=status_code,
286 300 status_message=status_message,
287 301 message=message,
288 302 exception=exception,
289 303 )
290 304
291 305 self.set_header('Content-Type', 'text/html')
292 306 # render the template
293 307 try:
294 308 html = self.render_template('%s.html' % status_code, **ns)
295 309 except TemplateNotFound:
296 310 self.log.debug("No template for %d", status_code)
297 311 html = self.render_template('error.html', **ns)
298 312
299 313 self.write(html)
300 314
301 315
302 316
303 317 class Template404(IPythonHandler):
304 318 """Render our 404 template"""
305 319 def prepare(self):
306 320 raise web.HTTPError(404)
307 321
308 322
309 323 class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):
310 324 """static files should only be accessible when logged in"""
311 325
312 326 @web.authenticated
313 327 def get(self, path):
314 328 if os.path.splitext(path)[1] == '.ipynb':
315 329 name = path.rsplit('/', 1)[-1]
316 330 self.set_header('Content-Type', 'application/json')
317 331 self.set_header('Content-Disposition','attachment; filename="%s"' % name)
318 332
319 333 return web.StaticFileHandler.get(self, path)
320 334
321 335 def set_headers(self):
322 336 super(AuthenticatedFileHandler, self).set_headers()
323 337 # disable browser caching, rely on 304 replies for savings
324 338 if "v" not in self.request.arguments:
325 339 self.add_header("Cache-Control", "no-cache")
326 340
327 341 def compute_etag(self):
328 342 return None
329 343
330 344 def validate_absolute_path(self, root, absolute_path):
331 345 """Validate and return the absolute path.
332 346
333 347 Requires tornado 3.1
334 348
335 349 Adding to tornado's own handling, forbids the serving of hidden files.
336 350 """
337 351 abs_path = super(AuthenticatedFileHandler, self).validate_absolute_path(root, absolute_path)
338 352 abs_root = os.path.abspath(root)
339 353 if is_hidden(abs_path, abs_root):
340 354 self.log.info("Refusing to serve hidden file, via 404 Error")
341 355 raise web.HTTPError(404)
342 356 return abs_path
343 357
344 358
345 359 def json_errors(method):
346 360 """Decorate methods with this to return GitHub style JSON errors.
347 361
348 362 This should be used on any JSON API on any handler method that can raise HTTPErrors.
349 363
350 364 This will grab the latest HTTPError exception using sys.exc_info
351 365 and then:
352 366
353 367 1. Set the HTTP status code based on the HTTPError
354 368 2. Create and return a JSON body with a message field describing
355 369 the error in a human readable form.
356 370 """
357 371 @functools.wraps(method)
358 372 def wrapper(self, *args, **kwargs):
359 373 try:
360 374 result = method(self, *args, **kwargs)
361 375 except web.HTTPError as e:
362 376 status = e.status_code
363 377 message = e.log_message
364 378 self.log.warn(message)
365 379 self.set_status(e.status_code)
366 380 self.finish(json.dumps(dict(message=message)))
367 381 except Exception:
368 382 self.log.error("Unhandled error in API request", exc_info=True)
369 383 status = 500
370 384 message = "Unknown server error"
371 385 t, value, tb = sys.exc_info()
372 386 self.set_status(status)
373 387 tb_text = ''.join(traceback.format_exception(t, value, tb))
374 388 reply = dict(message=message, traceback=tb_text)
375 389 self.finish(json.dumps(reply))
376 390 else:
377 391 return result
378 392 return wrapper
379 393
380 394
381 395
382 396 #-----------------------------------------------------------------------------
383 397 # File handler
384 398 #-----------------------------------------------------------------------------
385 399
386 400 # to minimize subclass changes:
387 401 HTTPError = web.HTTPError
388 402
389 403 class FileFindHandler(web.StaticFileHandler):
390 404 """subclass of StaticFileHandler for serving files from a search path"""
391 405
392 406 # cache search results, don't search for files more than once
393 407 _static_paths = {}
394 408
395 409 def set_headers(self):
396 410 super(FileFindHandler, self).set_headers()
397 411 # disable browser caching, rely on 304 replies for savings
398 412 if "v" not in self.request.arguments or \
399 413 any(self.request.path.startswith(path) for path in self.no_cache_paths):
400 414 self.add_header("Cache-Control", "no-cache")
401 415
402 416 def initialize(self, path, default_filename=None, no_cache_paths=None):
403 417 self.no_cache_paths = no_cache_paths or []
404 418
405 419 if isinstance(path, string_types):
406 420 path = [path]
407 421
408 422 self.root = tuple(
409 423 os.path.abspath(os.path.expanduser(p)) + os.sep for p in path
410 424 )
411 425 self.default_filename = default_filename
412 426
413 427 def compute_etag(self):
414 428 return None
415 429
416 430 @classmethod
417 431 def get_absolute_path(cls, roots, path):
418 432 """locate a file to serve on our static file search path"""
419 433 with cls._lock:
420 434 if path in cls._static_paths:
421 435 return cls._static_paths[path]
422 436 try:
423 437 abspath = os.path.abspath(filefind(path, roots))
424 438 except IOError:
425 439 # IOError means not found
426 440 return ''
427 441
428 442 cls._static_paths[path] = abspath
429 443 return abspath
430 444
431 445 def validate_absolute_path(self, root, absolute_path):
432 446 """check if the file should be served (raises 404, 403, etc.)"""
433 447 if absolute_path == '':
434 448 raise web.HTTPError(404)
435 449
436 450 for root in self.root:
437 451 if (absolute_path + os.sep).startswith(root):
438 452 break
439 453
440 454 return super(FileFindHandler, self).validate_absolute_path(root, absolute_path)
441 455
442 456
443 457 class ApiVersionHandler(IPythonHandler):
444 458
445 459 @json_errors
446 460 def get(self):
447 461 # not authenticated, so give as few info as possible
448 462 self.finish(json.dumps({"version":IPython.__version__}))
449 463
450 464
451 465 class TrailingSlashHandler(web.RequestHandler):
452 466 """Simple redirect handler that strips trailing slashes
453 467
454 468 This should be the first, highest priority handler.
455 469 """
456 470
457 471 def get(self):
458 472 self.redirect(self.request.uri.rstrip('/'))
459 473
460 474 post = put = get
461 475
462 476
463 477 class FilesRedirectHandler(IPythonHandler):
464 478 """Handler for redirecting relative URLs to the /files/ handler"""
465 479 def get(self, path=''):
466 480 cm = self.contents_manager
467 481 if cm.dir_exists(path):
468 482 # it's a *directory*, redirect to /tree
469 483 url = url_path_join(self.base_url, 'tree', path)
470 484 else:
471 485 orig_path = path
472 486 # otherwise, redirect to /files
473 487 parts = path.split('/')
474 488
475 489 if not cm.file_exists(path=path) and 'files' in parts:
476 490 # redirect without files/ iff it would 404
477 491 # this preserves pre-2.0-style 'files/' links
478 492 self.log.warn("Deprecated files/ URL: %s", orig_path)
479 493 parts.remove('files')
480 494 path = '/'.join(parts)
481 495
482 496 if not cm.file_exists(path=path):
483 497 raise web.HTTPError(404)
484 498
485 499 url = url_path_join(self.base_url, 'files', path)
486 500 url = url_escape(url)
487 501 self.log.debug("Redirecting %s to %s", self.request.path, url)
488 502 self.redirect(url)
489 503
490 504
491 505 #-----------------------------------------------------------------------------
492 506 # URL pattern fragments for re-use
493 507 #-----------------------------------------------------------------------------
494 508
495 509 # path matches any number of `/foo[/bar...]` or just `/` or ''
496 510 path_regex = r"(?P<path>(?:(?:/[^/]+)+|/?))"
497 511 notebook_path_regex = r"(?P<path>(?:/[^/]+)+\.ipynb)"
498 512
499 513 #-----------------------------------------------------------------------------
500 514 # URL to handler mappings
501 515 #-----------------------------------------------------------------------------
502 516
503 517
504 518 default_handlers = [
505 519 (r".*/", TrailingSlashHandler),
506 520 (r"api", ApiVersionHandler)
507 521 ]
@@ -1,17 +1,23 b''
1 #!/usr/bin/env python
2 # -*- coding: utf-8 -*-
1 """Tornado handlers for security logging."""
2
3 # Copyright (c) IPython Development Team.
4 # Distributed under the terms of the Modified BSD License.
5
6 from tornado import gen, web
7
8 from ...base.handlers import IPythonHandler, json_errors
3 9
4 10 class CSPReportHandler(IPythonHandler):
5 11 '''Accepts a content security policy violation report'''
6 12 @web.authenticated
7 13 @json_errors
8 14 def post(self):
9 15 '''Log a content security policy violation report'''
10 16 csp_report = self.get_json_body()
11 17 self.log.debug(csp_report)
12 18
13 19 csp_report_uri = r"/api/security/csp-report"
14 20
15 21 default_handlers = [
16 22 (csp_report_uri, CSPReportHandler)
17 23 ]
General Comments 0
You need to be logged in to leave comments. Login now