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) => {
|
||||
let base_url = window.base_url || "/",
|
||||
api_url = `${base_url}hub/api`;
|
||||
return fetch(api_url + endpoint, {
|
||||
let api_url = `${base_url}hub/api`;
|
||||
let suffix = "";
|
||||
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,
|
||||
json: true,
|
||||
headers: {
|
||||
|
@@ -15,6 +15,11 @@ from .base import APIHandler, BaseHandler
|
||||
|
||||
|
||||
class TokenAPIHandler(APIHandler):
|
||||
def check_xsrf_cookie(self):
|
||||
# no xsrf check needed here
|
||||
# post is just a 404
|
||||
return
|
||||
|
||||
@token_authenticated
|
||||
def get(self, token):
|
||||
# FIXME: deprecate this API for oauth token resolution, in favor of using /api/user
|
||||
@@ -378,31 +383,7 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
|
||||
@web.authenticated
|
||||
def post(self):
|
||||
uri, http_method, body, headers = self.extract_oauth_params()
|
||||
referer = self.request.headers.get('Referer', 'no referer')
|
||||
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"
|
||||
)
|
||||
# TODO: per-page xsrf token to verify origin on this page!
|
||||
|
||||
# The scopes the user actually authorized, i.e. checkboxes
|
||||
# that were selected.
|
||||
|
@@ -2,6 +2,7 @@
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
import json
|
||||
import warnings
|
||||
from functools import lru_cache
|
||||
from http.client import responses
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||
@@ -12,7 +13,7 @@ from tornado import web
|
||||
from .. import orm
|
||||
from ..handlers import BaseHandler
|
||||
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"
|
||||
|
||||
@@ -23,7 +24,6 @@ class APIHandler(BaseHandler):
|
||||
Differences from page handlers:
|
||||
|
||||
- JSON responses and errors
|
||||
- strict referer checking for Cookie-authenticated requests
|
||||
- strict content-security-policy
|
||||
- methods for REST API models
|
||||
"""
|
||||
@@ -49,48 +49,12 @@ class APIHandler(BaseHandler):
|
||||
return PAGINATION_MEDIA_TYPE in accepts
|
||||
|
||||
def check_referer(self):
|
||||
"""Check Origin for cross-site API requests.
|
||||
|
||||
Copied from WebSocket with changes:
|
||||
|
||||
- allow unspecified host/referer (e.g. scripts)
|
||||
"""
|
||||
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
|
||||
"""DEPRECATED"""
|
||||
warnings.warn(
|
||||
"check_referer is deprecated in JupyterHub 3.2 and always returns True",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
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
|
||||
|
||||
def check_post_content_type(self):
|
||||
@@ -111,6 +75,25 @@ class APIHandler(BaseHandler):
|
||||
|
||||
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):
|
||||
"""Extend get_user_cookie to add checks for CORS"""
|
||||
cookie_user = super().get_current_user_cookie()
|
||||
@@ -119,8 +102,6 @@ class APIHandler(BaseHandler):
|
||||
# avoiding misleading "Blocking Cross Origin" messages
|
||||
# when there's no cookie set anyway.
|
||||
if cookie_user:
|
||||
if not self.check_referer():
|
||||
return None
|
||||
if (
|
||||
self.request.method.upper() == 'POST'
|
||||
and not self.check_post_content_type()
|
||||
@@ -518,6 +499,9 @@ class API404(APIHandler):
|
||||
Ensures JSON 404 errors for malformed URLs
|
||||
"""
|
||||
|
||||
def check_xsrf_cookie(self):
|
||||
pass
|
||||
|
||||
async def prepare(self):
|
||||
await super().prepare()
|
||||
raise web.HTTPError(404)
|
||||
|
@@ -51,6 +51,9 @@ class ShutdownAPIHandler(APIHandler):
|
||||
|
||||
|
||||
class RootAPIHandler(APIHandler):
|
||||
def check_xsrf_cookie(self):
|
||||
return
|
||||
|
||||
def get(self):
|
||||
"""GET /api/ returns info about the Hub and its API.
|
||||
|
||||
|
@@ -337,6 +337,15 @@ class UserAPIHandler(APIHandler):
|
||||
class UserTokenListAPIHandler(APIHandler):
|
||||
"""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')
|
||||
def get(self, user_name):
|
||||
"""Get tokens for a given user"""
|
||||
@@ -374,6 +383,7 @@ class UserTokenListAPIHandler(APIHandler):
|
||||
if isinstance(name, dict):
|
||||
# not a simple string so it has to be a dict
|
||||
name = name.get('name')
|
||||
# don't check xsrf if we've authenticated via the request body
|
||||
except web.HTTPError as e:
|
||||
# turn any authentication error into 403
|
||||
raise web.HTTPError(403)
|
||||
@@ -384,7 +394,14 @@ class UserTokenListAPIHandler(APIHandler):
|
||||
"Error authenticating request for %s: %s", self.request.uri, e
|
||||
)
|
||||
raise web.HTTPError(403)
|
||||
if name is None:
|
||||
raise web.HTTPError(403)
|
||||
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:
|
||||
# couldn't identify requester
|
||||
raise web.HTTPError(403)
|
||||
|
@@ -2721,6 +2721,16 @@ class JupyterHub(Application):
|
||||
)
|
||||
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(
|
||||
log_function=log_request,
|
||||
config=self.config,
|
||||
@@ -2774,6 +2784,7 @@ class JupyterHub(Application):
|
||||
shutdown_on_logout=self.shutdown_on_logout,
|
||||
eventlog=self.eventlog,
|
||||
app=self,
|
||||
xsrf_cookies=True,
|
||||
)
|
||||
# allow configured settings to have priority
|
||||
settings.update(self.tornado_settings)
|
||||
|
@@ -233,6 +233,16 @@ class BaseHandler(RequestHandler):
|
||||
# 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
|
||||
def admin_users(self):
|
||||
return self.settings.setdefault('admin_users', set())
|
||||
@@ -380,6 +390,10 @@ class BaseHandler(RequestHandler):
|
||||
if recorded:
|
||||
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:
|
||||
return orm_token.service
|
||||
|
||||
@@ -717,7 +731,7 @@ class BaseHandler(RequestHandler):
|
||||
if not next_url_from_param:
|
||||
# when a request made with ?next=... assume all the params have already been encoded
|
||||
# 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
|
||||
|
||||
def append_query_parameters(self, url, exclude=None):
|
||||
@@ -1257,6 +1271,7 @@ class BaseHandler(RequestHandler):
|
||||
"""
|
||||
template_ns = {}
|
||||
template_ns.update(self.template_namespace)
|
||||
template_ns["xsrf_token"] = self.xsrf_token.decode("ascii")
|
||||
template_ns.update(ns)
|
||||
template = self.get_template(name, sync)
|
||||
if sync:
|
||||
@@ -1279,6 +1294,7 @@ class BaseHandler(RequestHandler):
|
||||
services=self.get_accessible_services(user),
|
||||
parsed_scopes=self.parsed_scopes,
|
||||
expanded_scopes=self.expanded_scopes,
|
||||
xsrf=self.xsrf_token.decode('ascii'),
|
||||
)
|
||||
if self.settings['template_vars']:
|
||||
ns.update(self.settings['template_vars'])
|
||||
|
@@ -101,7 +101,9 @@ class LoginHandler(BaseHandler):
|
||||
"login_url": self.settings['login_url'],
|
||||
"authenticator_login_url": url_concat(
|
||||
self.authenticator.login_url(self.hub.base_url),
|
||||
{'next': self.get_argument('next', '')},
|
||||
{
|
||||
'next': self.get_argument('next', ''),
|
||||
},
|
||||
),
|
||||
}
|
||||
custom_html = Template(
|
||||
@@ -147,7 +149,10 @@ class LoginHandler(BaseHandler):
|
||||
async def post(self):
|
||||
# parse the arguments dict
|
||||
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,
|
||||
# which should be allowed to start or end with space
|
||||
data[arg] = self.get_argument(arg, strip=arg == "username")
|
||||
|
@@ -99,7 +99,9 @@ class SpawnHandler(BaseHandler):
|
||||
auth_state=auth_state,
|
||||
spawner_options_form=spawner_options_form,
|
||||
error_message=message,
|
||||
url=self.request.uri,
|
||||
url=url_concat(
|
||||
self.request.uri, {"_xsrf": self.xsrf_token.decode('ascii')}
|
||||
),
|
||||
spawner=for_user.spawner,
|
||||
)
|
||||
|
||||
@@ -404,7 +406,9 @@ class SpawnPendingHandler(BaseHandler):
|
||||
page,
|
||||
user=user,
|
||||
spawner=spawner,
|
||||
progress_url=spawner._progress_url,
|
||||
progress_url=url_concat(
|
||||
spawner._progress_url, {"_xsrf": self.xsrf_token.decode('ascii')}
|
||||
),
|
||||
auth_state=auth_state,
|
||||
)
|
||||
self.finish(html)
|
||||
|
@@ -66,7 +66,7 @@ class CoroutineLogFormatter(LogFormatter):
|
||||
# url params to be scrubbed if seen
|
||||
# any url param that *contains* one of these
|
||||
# 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):
|
||||
|
@@ -36,6 +36,7 @@ from unittest import mock
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pamela import PAMError
|
||||
from tornado.httputil import url_concat
|
||||
from traitlets import Bool, Dict, default
|
||||
|
||||
from .. import metrics, orm, roles
|
||||
@@ -354,14 +355,25 @@ class MockHub(JupyterHub):
|
||||
external_ca = None
|
||||
if self.internal_ssl:
|
||||
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(
|
||||
base_url + 'hub/login',
|
||||
url_concat(login_url, {"_xsrf": xsrf}),
|
||||
cookies=r.cookies,
|
||||
data={'username': name, 'password': name},
|
||||
allow_redirects=False,
|
||||
verify=external_ca,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
|
@@ -6,7 +6,7 @@ import sys
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from unittest import mock
|
||||
from urllib.parse import quote, urlparse, urlunparse
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
from pytest import fixture, mark
|
||||
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
|
||||
|
||||
|
||||
@mark.parametrize("xsrf_in_url", [True, False])
|
||||
@mark.parametrize(
|
||||
"host, referer, extraheaders, status",
|
||||
"method, path",
|
||||
[
|
||||
('$host', '$url', {}, 200),
|
||||
(None, None, {}, 200),
|
||||
(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),
|
||||
("GET", "user"),
|
||||
("POST", "users/{username}/tokens"),
|
||||
],
|
||||
)
|
||||
async def test_cors_check(request, app, host, referer, extraheaders, status):
|
||||
url = ujoin(public_host(app), app.hub.base_url)
|
||||
real_host = urlparse(url).netloc
|
||||
if host == "$host":
|
||||
host = real_host
|
||||
|
||||
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)
|
||||
async def test_xsrf_check(app, username, method, path, xsrf_in_url):
|
||||
cookies = await app.login_user(username)
|
||||
xsrf = cookies['_xsrf']
|
||||
url = path.format(username=username)
|
||||
if xsrf_in_url:
|
||||
url = f"{url}?_xsrf={xsrf}"
|
||||
|
||||
r = await api_request(
|
||||
app,
|
||||
'users',
|
||||
headers=headers,
|
||||
url,
|
||||
noauth=True,
|
||||
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
|
||||
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
from requests.exceptions import HTTPError
|
||||
from tornado.httputil import url_concat
|
||||
|
||||
@@ -13,7 +14,7 @@ from .. import orm
|
||||
from ..utils import url_escape_path, url_path_join
|
||||
from .mocking import FormSpawner
|
||||
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
|
||||
@@ -372,6 +373,9 @@ async def test_named_server_spawn_form(app, username, named_servers):
|
||||
r.raise_for_status()
|
||||
assert r.url.endswith(f'/spawn/{username}/{server_name}')
|
||||
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
|
||||
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(
|
||||
url_concat(
|
||||
url_path_join(base_url, 'hub/spawn', username, server_name),
|
||||
action_url,
|
||||
{'next': next_url},
|
||||
),
|
||||
cookies=cookies,
|
||||
|
@@ -6,7 +6,6 @@ from urllib.parse import parse_qs, urlencode, urlparse
|
||||
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
from tornado.escape import url_escape
|
||||
from tornado.httputil import url_concat
|
||||
|
||||
from .. import orm, roles, scopes
|
||||
@@ -380,8 +379,15 @@ async def test_spawn_form(app):
|
||||
u = app.users[orm_u]
|
||||
await u.stop()
|
||||
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(
|
||||
url_concat(ujoin(base_url, 'spawn'), {'next': next_url}),
|
||||
action_url,
|
||||
cookies=cookies,
|
||||
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)
|
||||
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(
|
||||
url_concat(ujoin(base_url, 'spawn', user.name), {'next': next_url}),
|
||||
action_url,
|
||||
cookies=cookies,
|
||||
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')
|
||||
u = app.users[orm_u]
|
||||
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(
|
||||
ujoin(base_url, 'spawn'),
|
||||
action_url,
|
||||
cookies=cookies,
|
||||
data={'bounds': ['-1', '1'], 'energy': '511keV'},
|
||||
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(
|
||||
"url, token_in",
|
||||
[
|
||||
@@ -722,11 +700,13 @@ async def test_page_with_token(app, user, url, token_in):
|
||||
allow_redirects=False,
|
||||
)
|
||||
if "/hub/login" in r.url:
|
||||
cookies = {'_xsrf'}
|
||||
assert r.status_code == 200
|
||||
else:
|
||||
cookies = set()
|
||||
assert r.status_code == 302
|
||||
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):
|
||||
@@ -737,7 +717,7 @@ async def test_login_fail(app):
|
||||
data={'username': name, 'password': 'wrong'},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert not r.cookies
|
||||
assert set(r.cookies.keys()).issubset({"_xsrf"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -758,8 +738,16 @@ async def test_login_strip(app, form_user, auth_user, form_password):
|
||||
called_with.append(data)
|
||||
|
||||
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(
|
||||
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]
|
||||
|
@@ -339,7 +339,8 @@ async def test_oauth_service_roles(
|
||||
data = {}
|
||||
if 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()
|
||||
assert r.url == url
|
||||
# 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}
|
||||
|
||||
# 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()
|
||||
assert r.url == url
|
||||
# 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
|
||||
# submit the oauth form to complete authorization
|
||||
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()
|
||||
assert r.url == url
|
||||
@@ -561,7 +562,7 @@ async def test_oauth_cookie_collision(app, mockservice_url, create_user_with_sco
|
||||
|
||||
# finish oauth 1
|
||||
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()
|
||||
assert r.url == url
|
||||
@@ -606,7 +607,7 @@ async def test_oauth_logout(app, mockservice_url, create_user_with_scopes):
|
||||
r.raise_for_status()
|
||||
assert urlparse(r.url).path.endswith('oauth2/authorize')
|
||||
# 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()
|
||||
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.raise_for_status()
|
||||
# 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
|
||||
# i.e. redirect back to login page
|
||||
r = await s.get(url)
|
||||
|
@@ -114,7 +114,7 @@ async def test_singleuser_auth(
|
||||
return
|
||||
r.raise_for_status()
|
||||
# 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_path = url_path_join(
|
||||
'/user/', user.name, spawner.name, spawner.default_url or "/tree"
|
||||
|
@@ -6,6 +6,7 @@ from concurrent.futures import ThreadPoolExecutor
|
||||
import pytest
|
||||
import requests
|
||||
from certipy import Certipy
|
||||
from tornado.httputil import url_concat
|
||||
|
||||
from jupyterhub import metrics, orm
|
||||
from jupyterhub.objects import Server
|
||||
@@ -161,13 +162,14 @@ async def api_request(
|
||||
h.update(headers)
|
||||
h.update(auth_header(app.db, kwargs.pop('name', 'admin')))
|
||||
|
||||
url = ujoin(base_url, 'api', *api_path)
|
||||
|
||||
if 'cookies' in kwargs:
|
||||
# for cookie-authenticated requests,
|
||||
# set Referer so it looks like the request originated
|
||||
# from a Hub-served page
|
||||
headers.setdefault('Referer', ujoin(base_url, 'test'))
|
||||
# add _xsrf to url params
|
||||
if "_xsrf" in kwargs['cookies'] and not noauth:
|
||||
url = url_concat(url, {"_xsrf": kwargs['cookies']['_xsrf']})
|
||||
|
||||
url = ujoin(base_url, 'api', *api_path)
|
||||
f = getattr(async_requests, method)
|
||||
if app.internal_ssl:
|
||||
kwargs['cert'] = (app.internal_ssl_cert, app.internal_ssl_key)
|
||||
|
@@ -6,6 +6,7 @@ define(["jquery", "utils"], function ($, utils) {
|
||||
|
||||
var JHAPI = function (base_url) {
|
||||
this.base_url = base_url;
|
||||
this.xsrf_token = window.jhdata.xsrf_token;
|
||||
};
|
||||
|
||||
var default_options = {
|
||||
@@ -40,6 +41,11 @@ define(["jquery", "utils"], function ($, utils) {
|
||||
"api",
|
||||
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);
|
||||
};
|
||||
|
||||
|
@@ -20,12 +20,12 @@
|
||||
We strongly recommend enabling HTTPS for JupyterHub.
|
||||
</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}}
|
||||
</a>
|
||||
</div>
|
||||
{% 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">
|
||||
<h1>Sign in</h1>
|
||||
</div>
|
||||
@@ -41,6 +41,7 @@
|
||||
{{login_error}}
|
||||
</p>
|
||||
{% endif %}
|
||||
<input type="hidden" name="_xsrf" value="{{ xsrf }}"/>
|
||||
<label for="username_input">Username:</label>
|
||||
<input
|
||||
id="username_input"
|
||||
|
@@ -23,6 +23,7 @@
|
||||
<h3>This will grant the application permission to:</h3>
|
||||
<div>
|
||||
<form method="POST" action="">
|
||||
<input type="hidden" name="_xsrf" value="{{ xsrf }}"/>
|
||||
{# these are the 'real' inputs to the form -#}
|
||||
{% for scope in allowed_scopes %}
|
||||
<input type="hidden" name="scopes" value="{{ scope }}"/>
|
||||
|
@@ -80,6 +80,7 @@
|
||||
{% else %}
|
||||
options_form: false,
|
||||
{% endif %}
|
||||
xsrf_token: "{{ xsrf_token }}",
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@@ -20,7 +20,7 @@
|
||||
Error: {{error_message}}
|
||||
</p>
|
||||
{% 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}}
|
||||
<br>
|
||||
<div class="feedback-container">
|
||||
|
Reference in New Issue
Block a user