mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-17 15:03:02 +00:00
Use XSRF tokens for cross-site protections
Removes all Referer checks, which have proven unreliable and have never been particularly strong We can use XSRF on paths for more robust inter-path protections. - `_xsrf` is added for forms via hidden input - xsrf check is additionally applied to GET requests on API endpoints
This commit is contained in:
@@ -1,7 +1,16 @@
|
|||||||
|
const jhdata = window.jhdata || {};
|
||||||
|
const base_url = jhdata.base_url || "/";
|
||||||
|
const xsrfToken = jhdata.xsrf_token;
|
||||||
|
|
||||||
export const jhapiRequest = (endpoint, method, data) => {
|
export const jhapiRequest = (endpoint, method, data) => {
|
||||||
let base_url = window.base_url || "/",
|
let api_url = `${base_url}hub/api`;
|
||||||
api_url = `${base_url}hub/api`;
|
let suffix = "";
|
||||||
return fetch(api_url + endpoint, {
|
if (xsrfToken) {
|
||||||
|
// add xsrf token to url parameter
|
||||||
|
var sep = endpoint.indexOf("?") === -1 ? "?" : "&";
|
||||||
|
suffix = sep + "_xsrf=" + xsrf_token;
|
||||||
|
}
|
||||||
|
return fetch(api_url + endpoint + suffix, {
|
||||||
method: method,
|
method: method,
|
||||||
json: true,
|
json: true,
|
||||||
headers: {
|
headers: {
|
||||||
|
@@ -15,6 +15,11 @@ from .base import APIHandler, BaseHandler
|
|||||||
|
|
||||||
|
|
||||||
class TokenAPIHandler(APIHandler):
|
class TokenAPIHandler(APIHandler):
|
||||||
|
def check_xsrf_cookie(self):
|
||||||
|
# no xsrf check needed here
|
||||||
|
# post is just a 404
|
||||||
|
return
|
||||||
|
|
||||||
@token_authenticated
|
@token_authenticated
|
||||||
def get(self, token):
|
def get(self, token):
|
||||||
# FIXME: deprecate this API for oauth token resolution, in favor of using /api/user
|
# FIXME: deprecate this API for oauth token resolution, in favor of using /api/user
|
||||||
@@ -378,31 +383,7 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
|
|||||||
@web.authenticated
|
@web.authenticated
|
||||||
def post(self):
|
def post(self):
|
||||||
uri, http_method, body, headers = self.extract_oauth_params()
|
uri, http_method, body, headers = self.extract_oauth_params()
|
||||||
referer = self.request.headers.get('Referer', 'no referer')
|
# TODO: per-page xsrf token to verify origin on this page!
|
||||||
full_url = self.request.full_url()
|
|
||||||
# trim protocol, which cannot be trusted with multiple layers of proxies anyway
|
|
||||||
# Referer is set by browser, but full_url can be modified by proxy layers to appear as http
|
|
||||||
# when it is actually https
|
|
||||||
referer_proto, _, stripped_referer = referer.partition("://")
|
|
||||||
referer_proto = referer_proto.lower()
|
|
||||||
req_proto, _, stripped_full_url = full_url.partition("://")
|
|
||||||
req_proto = req_proto.lower()
|
|
||||||
if referer_proto != req_proto:
|
|
||||||
self.log.warning("Protocol mismatch: %s != %s", referer, full_url)
|
|
||||||
if req_proto == "https":
|
|
||||||
# insecure origin to secure target is not allowed
|
|
||||||
raise web.HTTPError(
|
|
||||||
403, "Not allowing authorization form submitted from insecure page"
|
|
||||||
)
|
|
||||||
if stripped_referer != stripped_full_url:
|
|
||||||
# OAuth post must be made to the URL it came from
|
|
||||||
self.log.error("Original OAuth POST from %s != %s", referer, full_url)
|
|
||||||
self.log.error(
|
|
||||||
"Stripped OAuth POST from %s != %s", stripped_referer, stripped_full_url
|
|
||||||
)
|
|
||||||
raise web.HTTPError(
|
|
||||||
403, "Authorization form must be sent from authorization page"
|
|
||||||
)
|
|
||||||
|
|
||||||
# The scopes the user actually authorized, i.e. checkboxes
|
# The scopes the user actually authorized, i.e. checkboxes
|
||||||
# that were selected.
|
# that were selected.
|
||||||
|
@@ -2,6 +2,7 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
import json
|
import json
|
||||||
|
import warnings
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from http.client import responses
|
from http.client import responses
|
||||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||||
@@ -12,7 +13,7 @@ from tornado import web
|
|||||||
from .. import orm
|
from .. import orm
|
||||||
from ..handlers import BaseHandler
|
from ..handlers import BaseHandler
|
||||||
from ..scopes import get_scopes_for
|
from ..scopes import get_scopes_for
|
||||||
from ..utils import get_browser_protocol, isoformat, url_escape_path, url_path_join
|
from ..utils import isoformat, url_escape_path, url_path_join
|
||||||
|
|
||||||
PAGINATION_MEDIA_TYPE = "application/jupyterhub-pagination+json"
|
PAGINATION_MEDIA_TYPE = "application/jupyterhub-pagination+json"
|
||||||
|
|
||||||
@@ -23,7 +24,6 @@ class APIHandler(BaseHandler):
|
|||||||
Differences from page handlers:
|
Differences from page handlers:
|
||||||
|
|
||||||
- JSON responses and errors
|
- JSON responses and errors
|
||||||
- strict referer checking for Cookie-authenticated requests
|
|
||||||
- strict content-security-policy
|
- strict content-security-policy
|
||||||
- methods for REST API models
|
- methods for REST API models
|
||||||
"""
|
"""
|
||||||
@@ -49,48 +49,12 @@ class APIHandler(BaseHandler):
|
|||||||
return PAGINATION_MEDIA_TYPE in accepts
|
return PAGINATION_MEDIA_TYPE in accepts
|
||||||
|
|
||||||
def check_referer(self):
|
def check_referer(self):
|
||||||
"""Check Origin for cross-site API requests.
|
"""DEPRECATED"""
|
||||||
|
warnings.warn(
|
||||||
Copied from WebSocket with changes:
|
"check_referer is deprecated in JupyterHub 3.2 and always returns True",
|
||||||
|
DeprecationWarning,
|
||||||
- allow unspecified host/referer (e.g. scripts)
|
stacklevel=2,
|
||||||
"""
|
|
||||||
host_header = self.app.forwarded_host_header or "Host"
|
|
||||||
host = self.request.headers.get(host_header)
|
|
||||||
if host and "," in host:
|
|
||||||
host = host.split(",", 1)[0].strip()
|
|
||||||
referer = self.request.headers.get("Referer")
|
|
||||||
|
|
||||||
# If no header is provided, assume it comes from a script/curl.
|
|
||||||
# We are only concerned with cross-site browser stuff here.
|
|
||||||
if not host:
|
|
||||||
self.log.warning("Blocking API request with no host")
|
|
||||||
return False
|
|
||||||
if not referer:
|
|
||||||
self.log.warning("Blocking API request with no referer")
|
|
||||||
return False
|
|
||||||
|
|
||||||
proto = get_browser_protocol(self.request)
|
|
||||||
|
|
||||||
full_host = f"{proto}://{host}{self.hub.base_url}"
|
|
||||||
host_url = urlparse(full_host)
|
|
||||||
referer_url = urlparse(referer)
|
|
||||||
# resolve default ports for http[s]
|
|
||||||
referer_port = referer_url.port or (
|
|
||||||
443 if referer_url.scheme == 'https' else 80
|
|
||||||
)
|
)
|
||||||
host_port = host_url.port or (443 if host_url.scheme == 'https' else 80)
|
|
||||||
if (
|
|
||||||
referer_url.scheme != host_url.scheme
|
|
||||||
or referer_url.hostname != host_url.hostname
|
|
||||||
or referer_port != host_port
|
|
||||||
or not (referer_url.path + "/").startswith(host_url.path)
|
|
||||||
):
|
|
||||||
self.log.warning(
|
|
||||||
f"Blocking Cross Origin API request. Referer: {referer},"
|
|
||||||
f" {host_header}: {host}, Host URL: {full_host}",
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def check_post_content_type(self):
|
def check_post_content_type(self):
|
||||||
@@ -111,6 +75,25 @@ class APIHandler(BaseHandler):
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
async def prepare(self):
|
||||||
|
await super().prepare()
|
||||||
|
# tornado only checks xsrf on non-GET
|
||||||
|
# we also check xsrf on GETs to API endpoints
|
||||||
|
# make sure this runs after auth, which happens in super().prepare()
|
||||||
|
if self.request.method not in {"HEAD", "OPTIONS"} and self.settings.get(
|
||||||
|
"xsrf_cookies"
|
||||||
|
):
|
||||||
|
self.check_xsrf_cookie()
|
||||||
|
|
||||||
|
def check_xsrf_cookie(self):
|
||||||
|
if not hasattr(self, '_jupyterhub_user'):
|
||||||
|
# called too early to check if we're token-authenticated
|
||||||
|
return
|
||||||
|
if getattr(self, '_token_authenticated', False):
|
||||||
|
# if token-authenticated, ignore XSRF
|
||||||
|
return
|
||||||
|
return super().check_xsrf_cookie()
|
||||||
|
|
||||||
def get_current_user_cookie(self):
|
def get_current_user_cookie(self):
|
||||||
"""Extend get_user_cookie to add checks for CORS"""
|
"""Extend get_user_cookie to add checks for CORS"""
|
||||||
cookie_user = super().get_current_user_cookie()
|
cookie_user = super().get_current_user_cookie()
|
||||||
@@ -119,8 +102,6 @@ class APIHandler(BaseHandler):
|
|||||||
# avoiding misleading "Blocking Cross Origin" messages
|
# avoiding misleading "Blocking Cross Origin" messages
|
||||||
# when there's no cookie set anyway.
|
# when there's no cookie set anyway.
|
||||||
if cookie_user:
|
if cookie_user:
|
||||||
if not self.check_referer():
|
|
||||||
return None
|
|
||||||
if (
|
if (
|
||||||
self.request.method.upper() == 'POST'
|
self.request.method.upper() == 'POST'
|
||||||
and not self.check_post_content_type()
|
and not self.check_post_content_type()
|
||||||
@@ -518,6 +499,9 @@ class API404(APIHandler):
|
|||||||
Ensures JSON 404 errors for malformed URLs
|
Ensures JSON 404 errors for malformed URLs
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def check_xsrf_cookie(self):
|
||||||
|
pass
|
||||||
|
|
||||||
async def prepare(self):
|
async def prepare(self):
|
||||||
await super().prepare()
|
await super().prepare()
|
||||||
raise web.HTTPError(404)
|
raise web.HTTPError(404)
|
||||||
|
@@ -51,6 +51,9 @@ class ShutdownAPIHandler(APIHandler):
|
|||||||
|
|
||||||
|
|
||||||
class RootAPIHandler(APIHandler):
|
class RootAPIHandler(APIHandler):
|
||||||
|
def check_xsrf_cookie(self):
|
||||||
|
return
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
"""GET /api/ returns info about the Hub and its API.
|
"""GET /api/ returns info about the Hub and its API.
|
||||||
|
|
||||||
|
@@ -337,6 +337,15 @@ class UserAPIHandler(APIHandler):
|
|||||||
class UserTokenListAPIHandler(APIHandler):
|
class UserTokenListAPIHandler(APIHandler):
|
||||||
"""API endpoint for listing/creating tokens"""
|
"""API endpoint for listing/creating tokens"""
|
||||||
|
|
||||||
|
# defer check_xsrf_cookie so we can accept auth
|
||||||
|
# in the `auth` request field, which shouldn't require xsrf cookies
|
||||||
|
_skip_post_check_xsrf = True
|
||||||
|
|
||||||
|
def check_xsrf_cookie(self):
|
||||||
|
if self.request.method == 'POST' and self._skip_post_check_xsrf:
|
||||||
|
return
|
||||||
|
return super().check_xsrf_cookie()
|
||||||
|
|
||||||
@needs_scope('read:tokens')
|
@needs_scope('read:tokens')
|
||||||
def get(self, user_name):
|
def get(self, user_name):
|
||||||
"""Get tokens for a given user"""
|
"""Get tokens for a given user"""
|
||||||
@@ -374,6 +383,7 @@ class UserTokenListAPIHandler(APIHandler):
|
|||||||
if isinstance(name, dict):
|
if isinstance(name, dict):
|
||||||
# not a simple string so it has to be a dict
|
# not a simple string so it has to be a dict
|
||||||
name = name.get('name')
|
name = name.get('name')
|
||||||
|
# don't check xsrf if we've authenticated via the request body
|
||||||
except web.HTTPError as e:
|
except web.HTTPError as e:
|
||||||
# turn any authentication error into 403
|
# turn any authentication error into 403
|
||||||
raise web.HTTPError(403)
|
raise web.HTTPError(403)
|
||||||
@@ -384,7 +394,14 @@ class UserTokenListAPIHandler(APIHandler):
|
|||||||
"Error authenticating request for %s: %s", self.request.uri, e
|
"Error authenticating request for %s: %s", self.request.uri, e
|
||||||
)
|
)
|
||||||
raise web.HTTPError(403)
|
raise web.HTTPError(403)
|
||||||
|
if name is None:
|
||||||
|
raise web.HTTPError(403)
|
||||||
requester = self.find_user(name)
|
requester = self.find_user(name)
|
||||||
|
else:
|
||||||
|
# perform delayed xsrf check
|
||||||
|
# if we aren't authenticating via the request body
|
||||||
|
self._skip_post_check_xsrf = False
|
||||||
|
self.check_xsrf_cookie()
|
||||||
if requester is None:
|
if requester is None:
|
||||||
# couldn't identify requester
|
# couldn't identify requester
|
||||||
raise web.HTTPError(403)
|
raise web.HTTPError(403)
|
||||||
|
@@ -2721,6 +2721,16 @@ class JupyterHub(Application):
|
|||||||
)
|
)
|
||||||
oauth_no_confirm_list.add(service.oauth_client_id)
|
oauth_no_confirm_list.add(service.oauth_client_id)
|
||||||
|
|
||||||
|
# configure xsrf cookie
|
||||||
|
# (user xsrf_cookie_kwargs works as override)
|
||||||
|
xsrf_cookie_kwargs = self.tornado_settings.setdefault("xsrf_cookie_kwargs", {})
|
||||||
|
if not xsrf_cookie_kwargs:
|
||||||
|
# default to cookie_options
|
||||||
|
xsrf_cookie_kwargs.update(self.tornado_settings.get("cookie_options", {}))
|
||||||
|
|
||||||
|
# restrict xsrf cookie to hub base path
|
||||||
|
xsrf_cookie_kwargs["path"] = self.hub.base_url
|
||||||
|
|
||||||
settings = dict(
|
settings = dict(
|
||||||
log_function=log_request,
|
log_function=log_request,
|
||||||
config=self.config,
|
config=self.config,
|
||||||
@@ -2774,6 +2784,7 @@ class JupyterHub(Application):
|
|||||||
shutdown_on_logout=self.shutdown_on_logout,
|
shutdown_on_logout=self.shutdown_on_logout,
|
||||||
eventlog=self.eventlog,
|
eventlog=self.eventlog,
|
||||||
app=self,
|
app=self,
|
||||||
|
xsrf_cookies=True,
|
||||||
)
|
)
|
||||||
# allow configured settings to have priority
|
# allow configured settings to have priority
|
||||||
settings.update(self.tornado_settings)
|
settings.update(self.tornado_settings)
|
||||||
|
@@ -233,6 +233,16 @@ class BaseHandler(RequestHandler):
|
|||||||
# Login and cookie-related
|
# Login and cookie-related
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def check_xsrf_cookie(self):
|
||||||
|
try:
|
||||||
|
return super().check_xsrf_cookie()
|
||||||
|
except Exception as e:
|
||||||
|
# ensure _juptyerhub_user is defined on rejected requests
|
||||||
|
if not hasattr(self, "_jupyterhub_user"):
|
||||||
|
self._jupyterhub_user = None
|
||||||
|
self._resolve_roles_and_scopes()
|
||||||
|
raise
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def admin_users(self):
|
def admin_users(self):
|
||||||
return self.settings.setdefault('admin_users', set())
|
return self.settings.setdefault('admin_users', set())
|
||||||
@@ -380,6 +390,10 @@ class BaseHandler(RequestHandler):
|
|||||||
if recorded:
|
if recorded:
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
|
# record that we've been token-authenticated
|
||||||
|
# XSRF checks are skipped when using token auth
|
||||||
|
self._token_authenticated = True
|
||||||
|
|
||||||
if orm_token.service:
|
if orm_token.service:
|
||||||
return orm_token.service
|
return orm_token.service
|
||||||
|
|
||||||
@@ -717,7 +731,7 @@ class BaseHandler(RequestHandler):
|
|||||||
if not next_url_from_param:
|
if not next_url_from_param:
|
||||||
# when a request made with ?next=... assume all the params have already been encoded
|
# when a request made with ?next=... assume all the params have already been encoded
|
||||||
# otherwise, preserve params from the current request across the redirect
|
# otherwise, preserve params from the current request across the redirect
|
||||||
next_url = self.append_query_parameters(next_url, exclude=['next'])
|
next_url = self.append_query_parameters(next_url, exclude=['next', '_xsrf'])
|
||||||
return next_url
|
return next_url
|
||||||
|
|
||||||
def append_query_parameters(self, url, exclude=None):
|
def append_query_parameters(self, url, exclude=None):
|
||||||
@@ -1257,6 +1271,7 @@ class BaseHandler(RequestHandler):
|
|||||||
"""
|
"""
|
||||||
template_ns = {}
|
template_ns = {}
|
||||||
template_ns.update(self.template_namespace)
|
template_ns.update(self.template_namespace)
|
||||||
|
template_ns["xsrf_token"] = self.xsrf_token.decode("ascii")
|
||||||
template_ns.update(ns)
|
template_ns.update(ns)
|
||||||
template = self.get_template(name, sync)
|
template = self.get_template(name, sync)
|
||||||
if sync:
|
if sync:
|
||||||
@@ -1279,6 +1294,7 @@ class BaseHandler(RequestHandler):
|
|||||||
services=self.get_accessible_services(user),
|
services=self.get_accessible_services(user),
|
||||||
parsed_scopes=self.parsed_scopes,
|
parsed_scopes=self.parsed_scopes,
|
||||||
expanded_scopes=self.expanded_scopes,
|
expanded_scopes=self.expanded_scopes,
|
||||||
|
xsrf=self.xsrf_token.decode('ascii'),
|
||||||
)
|
)
|
||||||
if self.settings['template_vars']:
|
if self.settings['template_vars']:
|
||||||
ns.update(self.settings['template_vars'])
|
ns.update(self.settings['template_vars'])
|
||||||
|
@@ -101,7 +101,9 @@ class LoginHandler(BaseHandler):
|
|||||||
"login_url": self.settings['login_url'],
|
"login_url": self.settings['login_url'],
|
||||||
"authenticator_login_url": url_concat(
|
"authenticator_login_url": url_concat(
|
||||||
self.authenticator.login_url(self.hub.base_url),
|
self.authenticator.login_url(self.hub.base_url),
|
||||||
{'next': self.get_argument('next', '')},
|
{
|
||||||
|
'next': self.get_argument('next', ''),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
custom_html = Template(
|
custom_html = Template(
|
||||||
@@ -147,7 +149,10 @@ class LoginHandler(BaseHandler):
|
|||||||
async def post(self):
|
async def post(self):
|
||||||
# parse the arguments dict
|
# parse the arguments dict
|
||||||
data = {}
|
data = {}
|
||||||
for arg in self.request.arguments:
|
for arg in self.request.body_arguments:
|
||||||
|
if arg == "_xsrf":
|
||||||
|
# don't include xsrf token in auth input
|
||||||
|
continue
|
||||||
# strip username, but not other fields like passwords,
|
# strip username, but not other fields like passwords,
|
||||||
# which should be allowed to start or end with space
|
# which should be allowed to start or end with space
|
||||||
data[arg] = self.get_argument(arg, strip=arg == "username")
|
data[arg] = self.get_argument(arg, strip=arg == "username")
|
||||||
|
@@ -99,7 +99,9 @@ class SpawnHandler(BaseHandler):
|
|||||||
auth_state=auth_state,
|
auth_state=auth_state,
|
||||||
spawner_options_form=spawner_options_form,
|
spawner_options_form=spawner_options_form,
|
||||||
error_message=message,
|
error_message=message,
|
||||||
url=self.request.uri,
|
url=url_concat(
|
||||||
|
self.request.uri, {"_xsrf": self.xsrf_token.decode('ascii')}
|
||||||
|
),
|
||||||
spawner=for_user.spawner,
|
spawner=for_user.spawner,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -404,7 +406,9 @@ class SpawnPendingHandler(BaseHandler):
|
|||||||
page,
|
page,
|
||||||
user=user,
|
user=user,
|
||||||
spawner=spawner,
|
spawner=spawner,
|
||||||
progress_url=spawner._progress_url,
|
progress_url=url_concat(
|
||||||
|
spawner._progress_url, {"_xsrf": self.xsrf_token.decode('ascii')}
|
||||||
|
),
|
||||||
auth_state=auth_state,
|
auth_state=auth_state,
|
||||||
)
|
)
|
||||||
self.finish(html)
|
self.finish(html)
|
||||||
|
@@ -66,7 +66,7 @@ class CoroutineLogFormatter(LogFormatter):
|
|||||||
# url params to be scrubbed if seen
|
# url params to be scrubbed if seen
|
||||||
# any url param that *contains* one of these
|
# any url param that *contains* one of these
|
||||||
# will be scrubbed from logs
|
# will be scrubbed from logs
|
||||||
SCRUB_PARAM_KEYS = ('token', 'auth', 'key', 'code', 'state')
|
SCRUB_PARAM_KEYS = ('token', 'auth', 'key', 'code', 'state', '_xsrf')
|
||||||
|
|
||||||
|
|
||||||
def _scrub_uri(uri):
|
def _scrub_uri(uri):
|
||||||
|
@@ -36,6 +36,7 @@ from unittest import mock
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from pamela import PAMError
|
from pamela import PAMError
|
||||||
|
from tornado.httputil import url_concat
|
||||||
from traitlets import Bool, Dict, default
|
from traitlets import Bool, Dict, default
|
||||||
|
|
||||||
from .. import metrics, orm, roles
|
from .. import metrics, orm, roles
|
||||||
@@ -354,14 +355,25 @@ class MockHub(JupyterHub):
|
|||||||
external_ca = None
|
external_ca = None
|
||||||
if self.internal_ssl:
|
if self.internal_ssl:
|
||||||
external_ca = self.external_certs['files']['ca']
|
external_ca = self.external_certs['files']['ca']
|
||||||
|
login_url = base_url + 'hub/login'
|
||||||
|
r = await async_requests.get(login_url)
|
||||||
|
r.raise_for_status()
|
||||||
|
xsrf = r.cookies['_xsrf']
|
||||||
|
|
||||||
r = await async_requests.post(
|
r = await async_requests.post(
|
||||||
base_url + 'hub/login',
|
url_concat(login_url, {"_xsrf": xsrf}),
|
||||||
|
cookies=r.cookies,
|
||||||
data={'username': name, 'password': name},
|
data={'username': name, 'password': name},
|
||||||
allow_redirects=False,
|
allow_redirects=False,
|
||||||
verify=external_ca,
|
verify=external_ca,
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.cookies
|
r.cookies["_xsrf"] = xsrf
|
||||||
|
assert sorted(r.cookies.keys()) == [
|
||||||
|
'_xsrf',
|
||||||
|
'jupyterhub-hub-login',
|
||||||
|
'jupyterhub-session-id',
|
||||||
|
]
|
||||||
return r.cookies
|
return r.cookies
|
||||||
|
|
||||||
|
|
||||||
|
@@ -6,7 +6,7 @@ import sys
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from urllib.parse import quote, urlparse, urlunparse
|
from urllib.parse import quote, urlparse
|
||||||
|
|
||||||
from pytest import fixture, mark
|
from pytest import fixture, mark
|
||||||
from tornado.httputil import url_concat
|
from tornado.httputil import url_concat
|
||||||
@@ -95,108 +95,31 @@ async def test_post_content_type(app, content_type, status):
|
|||||||
assert r.status_code == status
|
assert r.status_code == status
|
||||||
|
|
||||||
|
|
||||||
|
@mark.parametrize("xsrf_in_url", [True, False])
|
||||||
@mark.parametrize(
|
@mark.parametrize(
|
||||||
"host, referer, extraheaders, status",
|
"method, path",
|
||||||
[
|
[
|
||||||
('$host', '$url', {}, 200),
|
("GET", "user"),
|
||||||
(None, None, {}, 200),
|
("POST", "users/{username}/tokens"),
|
||||||
(None, 'null', {}, 403),
|
|
||||||
(None, 'http://attack.com/csrf/vulnerability', {}, 403),
|
|
||||||
('$host', {"path": "/user/someuser"}, {}, 403),
|
|
||||||
('$host', {"path": "{path}/foo/bar/subpath"}, {}, 200),
|
|
||||||
# mismatch host
|
|
||||||
("mismatch.com", "$url", {}, 403),
|
|
||||||
# explicit host, matches
|
|
||||||
("fake.example", {"netloc": "fake.example"}, {}, 200),
|
|
||||||
# explicit port, matches implicit port
|
|
||||||
("fake.example:80", {"netloc": "fake.example"}, {}, 200),
|
|
||||||
# explicit port, mismatch
|
|
||||||
("fake.example:81", {"netloc": "fake.example"}, {}, 403),
|
|
||||||
# implicit ports, mismatch proto
|
|
||||||
("fake.example", {"netloc": "fake.example", "scheme": "https"}, {}, 403),
|
|
||||||
# explicit ports, match
|
|
||||||
("fake.example:81", {"netloc": "fake.example:81"}, {}, 200),
|
|
||||||
# Test proxy protocol defined headers taken into account by utils.get_browser_protocol
|
|
||||||
(
|
|
||||||
"fake.example",
|
|
||||||
{"netloc": "fake.example", "scheme": "https"},
|
|
||||||
{'X-Scheme': 'https'},
|
|
||||||
200,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"fake.example",
|
|
||||||
{"netloc": "fake.example", "scheme": "https"},
|
|
||||||
{'X-Forwarded-Proto': 'https'},
|
|
||||||
200,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"fake.example",
|
|
||||||
{"netloc": "fake.example", "scheme": "https"},
|
|
||||||
{
|
|
||||||
'Forwarded': 'host=fake.example;proto=https,for=1.2.34;proto=http',
|
|
||||||
'X-Scheme': 'http',
|
|
||||||
},
|
|
||||||
200,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"fake.example",
|
|
||||||
{"netloc": "fake.example", "scheme": "https"},
|
|
||||||
{
|
|
||||||
'Forwarded': 'host=fake.example;proto=http,for=1.2.34;proto=http',
|
|
||||||
'X-Scheme': 'https',
|
|
||||||
},
|
|
||||||
403,
|
|
||||||
),
|
|
||||||
("fake.example", {"netloc": "fake.example"}, {'X-Scheme': 'https'}, 403),
|
|
||||||
("fake.example", {"netloc": "fake.example"}, {'X-Scheme': 'https, http'}, 403),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_cors_check(request, app, host, referer, extraheaders, status):
|
async def test_xsrf_check(app, username, method, path, xsrf_in_url):
|
||||||
url = ujoin(public_host(app), app.hub.base_url)
|
cookies = await app.login_user(username)
|
||||||
real_host = urlparse(url).netloc
|
xsrf = cookies['_xsrf']
|
||||||
if host == "$host":
|
url = path.format(username=username)
|
||||||
host = real_host
|
if xsrf_in_url:
|
||||||
|
url = f"{url}?_xsrf={xsrf}"
|
||||||
if referer == '$url':
|
|
||||||
referer = url
|
|
||||||
elif isinstance(referer, dict):
|
|
||||||
parsed_url = urlparse(url)
|
|
||||||
# apply {}
|
|
||||||
url_ns = {key: getattr(parsed_url, key) for key in parsed_url._fields}
|
|
||||||
for key, value in referer.items():
|
|
||||||
referer[key] = value.format(**url_ns)
|
|
||||||
referer = urlunparse(parsed_url._replace(**referer))
|
|
||||||
|
|
||||||
# disable default auth header, cors is for cookie auth
|
|
||||||
headers = {"Authorization": ""}
|
|
||||||
if host is not None:
|
|
||||||
headers['X-Forwarded-Host'] = host
|
|
||||||
if referer is not None:
|
|
||||||
headers['Referer'] = referer
|
|
||||||
headers.update(extraheaders)
|
|
||||||
|
|
||||||
# add admin user
|
|
||||||
user = find_user(app.db, 'admin')
|
|
||||||
if user is None:
|
|
||||||
user = add_user(app.db, name='admin', admin=True)
|
|
||||||
cookies = await app.login_user('admin')
|
|
||||||
|
|
||||||
# test custom forwarded_host_header behavior
|
|
||||||
app.forwarded_host_header = 'X-Forwarded-Host'
|
|
||||||
|
|
||||||
# reset the config after the test to avoid leaking state
|
|
||||||
def reset_header():
|
|
||||||
app.forwarded_host_header = ""
|
|
||||||
|
|
||||||
request.addfinalizer(reset_header)
|
|
||||||
|
|
||||||
r = await api_request(
|
r = await api_request(
|
||||||
app,
|
app,
|
||||||
'users',
|
url,
|
||||||
headers=headers,
|
noauth=True,
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
)
|
)
|
||||||
assert r.status_code == status
|
if xsrf_in_url:
|
||||||
|
assert r.status_code == 200
|
||||||
|
else:
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
# --------------
|
# --------------
|
||||||
|
@@ -6,6 +6,7 @@ from unittest import mock
|
|||||||
from urllib.parse import unquote, urlencode, urlparse
|
from urllib.parse import unquote, urlencode, urlparse
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
from requests.exceptions import HTTPError
|
from requests.exceptions import HTTPError
|
||||||
from tornado.httputil import url_concat
|
from tornado.httputil import url_concat
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ from .. import orm
|
|||||||
from ..utils import url_escape_path, url_path_join
|
from ..utils import url_escape_path, url_path_join
|
||||||
from .mocking import FormSpawner
|
from .mocking import FormSpawner
|
||||||
from .test_api import TIMESTAMP, add_user, api_request, fill_user, normalize_user
|
from .test_api import TIMESTAMP, add_user, api_request, fill_user, normalize_user
|
||||||
from .utils import async_requests, get_page, public_url
|
from .utils import async_requests, get_page, public_host, public_url
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -372,6 +373,9 @@ async def test_named_server_spawn_form(app, username, named_servers):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.url.endswith(f'/spawn/{username}/{server_name}')
|
assert r.url.endswith(f'/spawn/{username}/{server_name}')
|
||||||
assert FormSpawner.options_form in r.text
|
assert FormSpawner.options_form in r.text
|
||||||
|
spawn_page = BeautifulSoup(r.text, 'html.parser')
|
||||||
|
form = spawn_page.find("form")
|
||||||
|
action_url = public_host(app) + form["action"]
|
||||||
|
|
||||||
# submit the form
|
# submit the form
|
||||||
next_url = url_path_join(
|
next_url = url_path_join(
|
||||||
@@ -379,7 +383,7 @@ async def test_named_server_spawn_form(app, username, named_servers):
|
|||||||
)
|
)
|
||||||
r = await async_requests.post(
|
r = await async_requests.post(
|
||||||
url_concat(
|
url_concat(
|
||||||
url_path_join(base_url, 'hub/spawn', username, server_name),
|
action_url,
|
||||||
{'next': next_url},
|
{'next': next_url},
|
||||||
),
|
),
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
|
@@ -6,7 +6,6 @@ from urllib.parse import parse_qs, urlencode, urlparse
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from tornado.escape import url_escape
|
|
||||||
from tornado.httputil import url_concat
|
from tornado.httputil import url_concat
|
||||||
|
|
||||||
from .. import orm, roles, scopes
|
from .. import orm, roles, scopes
|
||||||
@@ -380,8 +379,15 @@ async def test_spawn_form(app):
|
|||||||
u = app.users[orm_u]
|
u = app.users[orm_u]
|
||||||
await u.stop()
|
await u.stop()
|
||||||
next_url = ujoin(app.base_url, 'user/jones/tree')
|
next_url = ujoin(app.base_url, 'user/jones/tree')
|
||||||
|
r = await async_requests.get(
|
||||||
|
url_concat(ujoin(base_url, 'spawn'), {'next': next_url}), cookies=cookies
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
spawn_page = BeautifulSoup(r.text, 'html.parser')
|
||||||
|
form = spawn_page.find("form")
|
||||||
|
action_url = public_host(app) + form["action"]
|
||||||
r = await async_requests.post(
|
r = await async_requests.post(
|
||||||
url_concat(ujoin(base_url, 'spawn'), {'next': next_url}),
|
action_url,
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
data={'bounds': ['-1', '1'], 'energy': '511keV'},
|
data={'bounds': ['-1', '1'], 'energy': '511keV'},
|
||||||
)
|
)
|
||||||
@@ -419,8 +425,22 @@ async def test_spawn_form_other_user(
|
|||||||
base_url = ujoin(public_host(app), app.hub.base_url)
|
base_url = ujoin(public_host(app), app.hub.base_url)
|
||||||
next_url = ujoin(app.base_url, 'user', user.name, 'tree')
|
next_url = ujoin(app.base_url, 'user', user.name, 'tree')
|
||||||
|
|
||||||
|
url = ujoin(base_url, 'spawn', user.name)
|
||||||
|
r = await async_requests.get(
|
||||||
|
url_concat(url, {'next': next_url}),
|
||||||
|
cookies=cookies,
|
||||||
|
)
|
||||||
|
if has_access:
|
||||||
|
r.raise_for_status()
|
||||||
|
spawn_page = BeautifulSoup(r.text, 'html.parser')
|
||||||
|
form = spawn_page.find("form")
|
||||||
|
action_url = ujoin(public_host(app), form["action"])
|
||||||
|
else:
|
||||||
|
assert r.status_code == 404
|
||||||
|
action_url = url_concat(url, {"_xsrf": cookies['_xsrf']})
|
||||||
|
|
||||||
r = await async_requests.post(
|
r = await async_requests.post(
|
||||||
url_concat(ujoin(base_url, 'spawn', user.name), {'next': next_url}),
|
action_url,
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
data={'bounds': ['-3', '3'], 'energy': '938MeV'},
|
data={'bounds': ['-3', '3'], 'energy': '938MeV'},
|
||||||
)
|
)
|
||||||
@@ -450,9 +470,19 @@ async def test_spawn_form_with_file(app):
|
|||||||
orm_u = orm.User.find(app.db, 'jones')
|
orm_u = orm.User.find(app.db, 'jones')
|
||||||
u = app.users[orm_u]
|
u = app.users[orm_u]
|
||||||
await u.stop()
|
await u.stop()
|
||||||
|
url = ujoin(base_url, 'spawn')
|
||||||
|
|
||||||
|
r = await async_requests.get(
|
||||||
|
url,
|
||||||
|
cookies=cookies,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
spawn_page = BeautifulSoup(r.text, 'html.parser')
|
||||||
|
form = spawn_page.find("form")
|
||||||
|
action_url = public_host(app) + form["action"]
|
||||||
|
|
||||||
r = await async_requests.post(
|
r = await async_requests.post(
|
||||||
ujoin(base_url, 'spawn'),
|
action_url,
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
data={'bounds': ['-1', '1'], 'energy': '511keV'},
|
data={'bounds': ['-1', '1'], 'energy': '511keV'},
|
||||||
files={'hello': ('hello.txt', b'hello world\n')},
|
files={'hello': ('hello.txt', b'hello world\n')},
|
||||||
@@ -642,58 +672,6 @@ async def test_other_user_url(app, username, user, group, create_temp_role, has_
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
'url, params, redirected_url, form_action',
|
|
||||||
[
|
|
||||||
(
|
|
||||||
# spawn?param=value
|
|
||||||
# will encode given parameters for an unauthenticated URL in the next url
|
|
||||||
# the next parameter will contain the app base URL (replaces BASE_URL in tests)
|
|
||||||
'spawn',
|
|
||||||
[('param', 'value')],
|
|
||||||
'/hub/login?next={{BASE_URL}}hub%2Fspawn%3Fparam%3Dvalue',
|
|
||||||
'/hub/login?next={{BASE_URL}}hub%2Fspawn%3Fparam%3Dvalue',
|
|
||||||
),
|
|
||||||
(
|
|
||||||
# login?param=fromlogin&next=encoded(/hub/spawn?param=value)
|
|
||||||
# will drop parameters given to the login page, passing only the next url
|
|
||||||
'login',
|
|
||||||
[('param', 'fromlogin'), ('next', '/hub/spawn?param=value')],
|
|
||||||
'/hub/login?param=fromlogin&next=%2Fhub%2Fspawn%3Fparam%3Dvalue',
|
|
||||||
'/hub/login?next=%2Fhub%2Fspawn%3Fparam%3Dvalue',
|
|
||||||
),
|
|
||||||
(
|
|
||||||
# login?param=value&anotherparam=anothervalue
|
|
||||||
# will drop parameters given to the login page, and use an empty next url
|
|
||||||
'login',
|
|
||||||
[('param', 'value'), ('anotherparam', 'anothervalue')],
|
|
||||||
'/hub/login?param=value&anotherparam=anothervalue',
|
|
||||||
'/hub/login?next=',
|
|
||||||
),
|
|
||||||
(
|
|
||||||
# login
|
|
||||||
# simplest case, accessing the login URL, gives an empty next url
|
|
||||||
'login',
|
|
||||||
[],
|
|
||||||
'/hub/login',
|
|
||||||
'/hub/login?next=',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def test_login_page(app, url, params, redirected_url, form_action):
|
|
||||||
url = url_concat(url, params)
|
|
||||||
r = await get_page(url, app)
|
|
||||||
redirected_url = redirected_url.replace('{{BASE_URL}}', url_escape(app.base_url))
|
|
||||||
assert r.url.endswith(redirected_url)
|
|
||||||
# now the login.html rendered template must include the given parameters in the form
|
|
||||||
# action URL, including the next URL
|
|
||||||
page = BeautifulSoup(r.text, "html.parser")
|
|
||||||
form = page.find("form", method="post")
|
|
||||||
action = form.attrs['action']
|
|
||||||
form_action = form_action.replace('{{BASE_URL}}', url_escape(app.base_url))
|
|
||||||
assert action.endswith(form_action)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"url, token_in",
|
"url, token_in",
|
||||||
[
|
[
|
||||||
@@ -722,11 +700,13 @@ async def test_page_with_token(app, user, url, token_in):
|
|||||||
allow_redirects=False,
|
allow_redirects=False,
|
||||||
)
|
)
|
||||||
if "/hub/login" in r.url:
|
if "/hub/login" in r.url:
|
||||||
|
cookies = {'_xsrf'}
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
else:
|
else:
|
||||||
|
cookies = set()
|
||||||
assert r.status_code == 302
|
assert r.status_code == 302
|
||||||
assert r.headers["location"].partition("?")[0].endswith("/hub/login")
|
assert r.headers["location"].partition("?")[0].endswith("/hub/login")
|
||||||
assert not r.cookies
|
assert {c.name for c in r.cookies} == cookies
|
||||||
|
|
||||||
|
|
||||||
async def test_login_fail(app):
|
async def test_login_fail(app):
|
||||||
@@ -737,7 +717,7 @@ async def test_login_fail(app):
|
|||||||
data={'username': name, 'password': 'wrong'},
|
data={'username': name, 'password': 'wrong'},
|
||||||
allow_redirects=False,
|
allow_redirects=False,
|
||||||
)
|
)
|
||||||
assert not r.cookies
|
assert set(r.cookies.keys()).issubset({"_xsrf"})
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -758,8 +738,16 @@ async def test_login_strip(app, form_user, auth_user, form_password):
|
|||||||
called_with.append(data)
|
called_with.append(data)
|
||||||
|
|
||||||
with mock.patch.object(app.authenticator, 'authenticate', mock_authenticate):
|
with mock.patch.object(app.authenticator, 'authenticate', mock_authenticate):
|
||||||
|
r = await async_requests.get(base_url + 'hub/login')
|
||||||
|
r.raise_for_status()
|
||||||
|
cookies = r.cookies
|
||||||
|
xsrf = cookies['_xsrf']
|
||||||
|
page = BeautifulSoup(r.text, "html.parser")
|
||||||
|
action_url = public_host(app) + page.find("form")["action"]
|
||||||
|
xsrf_input = page.find("form").find("input", attrs={"name": "_xsrf"})
|
||||||
|
form_data["_xsrf"] = xsrf_input["value"]
|
||||||
await async_requests.post(
|
await async_requests.post(
|
||||||
base_url + 'hub/login', data=form_data, allow_redirects=False
|
action_url, data=form_data, allow_redirects=False, cookies=cookies
|
||||||
)
|
)
|
||||||
|
|
||||||
assert called_with == [expected_auth]
|
assert called_with == [expected_auth]
|
||||||
|
@@ -339,7 +339,8 @@ async def test_oauth_service_roles(
|
|||||||
data = {}
|
data = {}
|
||||||
if scope_values:
|
if scope_values:
|
||||||
data["scopes"] = scope_values
|
data["scopes"] = scope_values
|
||||||
r = await s.post(r.url, data=data, headers={'Referer': r.url})
|
data["_xsrf"] = s.cookies["_xsrf"]
|
||||||
|
r = await s.post(r.url, data=data)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.url == url
|
assert r.url == url
|
||||||
# verify oauth cookie is set
|
# verify oauth cookie is set
|
||||||
@@ -436,7 +437,7 @@ async def test_oauth_access_scopes(
|
|||||||
assert set(r.history[0].cookies.keys()) == {'service-%s-oauth-state' % service.name}
|
assert set(r.history[0].cookies.keys()) == {'service-%s-oauth-state' % service.name}
|
||||||
|
|
||||||
# submit the oauth form to complete authorization
|
# submit the oauth form to complete authorization
|
||||||
r = await s.post(r.url, headers={'Referer': r.url})
|
r = await s.post(r.url, data={"_xsrf": s.cookies["_xsrf"]})
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.url == url
|
assert r.url == url
|
||||||
# verify oauth cookie is set
|
# verify oauth cookie is set
|
||||||
@@ -549,7 +550,7 @@ async def test_oauth_cookie_collision(app, mockservice_url, create_user_with_sco
|
|||||||
# finish oauth 2
|
# finish oauth 2
|
||||||
# submit the oauth form to complete authorization
|
# submit the oauth form to complete authorization
|
||||||
r = await s.post(
|
r = await s.post(
|
||||||
oauth_2.url, data={'scopes': ['identify']}, headers={'Referer': oauth_2.url}
|
oauth_2.url, data={'scopes': ['identify'], "_xsrf": s.cookies["_xsrf"]}
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.url == url
|
assert r.url == url
|
||||||
@@ -561,7 +562,7 @@ async def test_oauth_cookie_collision(app, mockservice_url, create_user_with_sco
|
|||||||
|
|
||||||
# finish oauth 1
|
# finish oauth 1
|
||||||
r = await s.post(
|
r = await s.post(
|
||||||
oauth_1.url, data={'scopes': ['identify']}, headers={'Referer': oauth_1.url}
|
oauth_1.url, data={'scopes': ['identify'], "_xsrf": s.cookies["_xsrf"]}
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.url == url
|
assert r.url == url
|
||||||
@@ -606,7 +607,7 @@ async def test_oauth_logout(app, mockservice_url, create_user_with_scopes):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert urlparse(r.url).path.endswith('oauth2/authorize')
|
assert urlparse(r.url).path.endswith('oauth2/authorize')
|
||||||
# submit the oauth form to complete authorization
|
# submit the oauth form to complete authorization
|
||||||
r = await s.post(r.url, data={'scopes': ['identify']}, headers={'Referer': r.url})
|
r = await s.post(r.url, data={'scopes': ['identify'], "_xsrf": s.cookies["_xsrf"]})
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.url == url
|
assert r.url == url
|
||||||
|
|
||||||
@@ -631,7 +632,7 @@ async def test_oauth_logout(app, mockservice_url, create_user_with_scopes):
|
|||||||
r = await s.get(public_url(app, path='hub/logout'))
|
r = await s.get(public_url(app, path='hub/logout'))
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
# verify that all cookies other than the service cookie are cleared
|
# verify that all cookies other than the service cookie are cleared
|
||||||
assert list(s.cookies.keys()) == [service_cookie_name]
|
assert sorted(s.cookies.keys()) == ["_xsrf", service_cookie_name]
|
||||||
# verify that clearing session id invalidates service cookie
|
# verify that clearing session id invalidates service cookie
|
||||||
# i.e. redirect back to login page
|
# i.e. redirect back to login page
|
||||||
r = await s.get(url)
|
r = await s.get(url)
|
||||||
|
@@ -114,7 +114,7 @@ async def test_singleuser_auth(
|
|||||||
return
|
return
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
# submit the oauth form to complete authorization
|
# submit the oauth form to complete authorization
|
||||||
r = await s.post(r.url, data={'scopes': ['identify']}, headers={'Referer': r.url})
|
r = await s.post(r.url, data={'scopes': ['identify'], '_xsrf': s.cookies['_xsrf']})
|
||||||
final_url = urlparse(r.url).path.rstrip('/')
|
final_url = urlparse(r.url).path.rstrip('/')
|
||||||
final_path = url_path_join(
|
final_path = url_path_join(
|
||||||
'/user/', user.name, spawner.name, spawner.default_url or "/tree"
|
'/user/', user.name, spawner.name, spawner.default_url or "/tree"
|
||||||
|
@@ -6,6 +6,7 @@ from concurrent.futures import ThreadPoolExecutor
|
|||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
from certipy import Certipy
|
from certipy import Certipy
|
||||||
|
from tornado.httputil import url_concat
|
||||||
|
|
||||||
from jupyterhub import metrics, orm
|
from jupyterhub import metrics, orm
|
||||||
from jupyterhub.objects import Server
|
from jupyterhub.objects import Server
|
||||||
@@ -161,13 +162,14 @@ async def api_request(
|
|||||||
h.update(headers)
|
h.update(headers)
|
||||||
h.update(auth_header(app.db, kwargs.pop('name', 'admin')))
|
h.update(auth_header(app.db, kwargs.pop('name', 'admin')))
|
||||||
|
|
||||||
|
url = ujoin(base_url, 'api', *api_path)
|
||||||
|
|
||||||
if 'cookies' in kwargs:
|
if 'cookies' in kwargs:
|
||||||
# for cookie-authenticated requests,
|
# for cookie-authenticated requests,
|
||||||
# set Referer so it looks like the request originated
|
# add _xsrf to url params
|
||||||
# from a Hub-served page
|
if "_xsrf" in kwargs['cookies'] and not noauth:
|
||||||
headers.setdefault('Referer', ujoin(base_url, 'test'))
|
url = url_concat(url, {"_xsrf": kwargs['cookies']['_xsrf']})
|
||||||
|
|
||||||
url = ujoin(base_url, 'api', *api_path)
|
|
||||||
f = getattr(async_requests, method)
|
f = getattr(async_requests, method)
|
||||||
if app.internal_ssl:
|
if app.internal_ssl:
|
||||||
kwargs['cert'] = (app.internal_ssl_cert, app.internal_ssl_key)
|
kwargs['cert'] = (app.internal_ssl_cert, app.internal_ssl_key)
|
||||||
|
@@ -6,6 +6,7 @@ define(["jquery", "utils"], function ($, utils) {
|
|||||||
|
|
||||||
var JHAPI = function (base_url) {
|
var JHAPI = function (base_url) {
|
||||||
this.base_url = base_url;
|
this.base_url = base_url;
|
||||||
|
this.xsrf_token = window.jhdata.xsrf_token;
|
||||||
};
|
};
|
||||||
|
|
||||||
var default_options = {
|
var default_options = {
|
||||||
@@ -40,6 +41,11 @@ define(["jquery", "utils"], function ($, utils) {
|
|||||||
"api",
|
"api",
|
||||||
utils.encode_uri_components(path),
|
utils.encode_uri_components(path),
|
||||||
);
|
);
|
||||||
|
if (this.xsrf_token) {
|
||||||
|
// add xsrf token to url parameter
|
||||||
|
var sep = url.indexOf("?") === -1 ? "?" : "&";
|
||||||
|
url = url + sep + "_xsrf=" + this.xsrf_token;
|
||||||
|
}
|
||||||
$.ajax(url, options);
|
$.ajax(url, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -20,12 +20,12 @@
|
|||||||
We strongly recommend enabling HTTPS for JupyterHub.
|
We strongly recommend enabling HTTPS for JupyterHub.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a role="button" class='btn btn-jupyter btn-lg' href='{{authenticator_login_url}}'>
|
<a role="button" class='btn btn-jupyter btn-lg' href='{{ authenticator_login_url | safe }}'>
|
||||||
Sign in with {{login_service}}
|
Sign in with {{login_service}}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form action="{{authenticator_login_url}}" method="post" role="form">
|
<form action="{{ authenticator_login_url | safe }}" method="post" role="form">
|
||||||
<div class="auth-form-header">
|
<div class="auth-form-header">
|
||||||
<h1>Sign in</h1>
|
<h1>Sign in</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
{{login_error}}
|
{{login_error}}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<input type="hidden" name="_xsrf" value="{{ xsrf }}"/>
|
||||||
<label for="username_input">Username:</label>
|
<label for="username_input">Username:</label>
|
||||||
<input
|
<input
|
||||||
id="username_input"
|
id="username_input"
|
||||||
|
@@ -23,6 +23,7 @@
|
|||||||
<h3>This will grant the application permission to:</h3>
|
<h3>This will grant the application permission to:</h3>
|
||||||
<div>
|
<div>
|
||||||
<form method="POST" action="">
|
<form method="POST" action="">
|
||||||
|
<input type="hidden" name="_xsrf" value="{{ xsrf }}"/>
|
||||||
{# these are the 'real' inputs to the form -#}
|
{# these are the 'real' inputs to the form -#}
|
||||||
{% for scope in allowed_scopes %}
|
{% for scope in allowed_scopes %}
|
||||||
<input type="hidden" name="scopes" value="{{ scope }}"/>
|
<input type="hidden" name="scopes" value="{{ scope }}"/>
|
||||||
|
@@ -80,6 +80,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
options_form: false,
|
options_form: false,
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
xsrf_token: "{{ xsrf_token }}",
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@@ -20,7 +20,7 @@
|
|||||||
Error: {{error_message}}
|
Error: {{error_message}}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form enctype="multipart/form-data" id="spawn_form" action="{{url}}" method="post" role="form">
|
<form enctype="multipart/form-data" id="spawn_form" action="{{ url | safe }}" method="post" role="form">
|
||||||
{{spawner_options_form | safe}}
|
{{spawner_options_form | safe}}
|
||||||
<br>
|
<br>
|
||||||
<div class="feedback-container">
|
<div class="feedback-container">
|
||||||
|
Reference in New Issue
Block a user