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:
Min RK
2022-09-09 13:06:06 +02:00
parent 995264ffef
commit abe1136cba
22 changed files with 219 additions and 250 deletions

View File

@@ -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: {

View File

@@ -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.

View File

@@ -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)

View File

@@ -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.

View File

@@ -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)

View File

@@ -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)

View File

@@ -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'])

View File

@@ -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")

View File

@@ -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)

View File

@@ -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):

View File

@@ -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

View File

@@ -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
# --------------

View File

@@ -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,

View File

@@ -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]

View File

@@ -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)

View File

@@ -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"

View File

@@ -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)

View File

@@ -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);
};

View File

@@ -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"

View File

@@ -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 }}"/>

View File

@@ -80,6 +80,7 @@
{% else %}
options_form: false,
{% endif %}
xsrf_token: "{{ xsrf_token }}",
}
</script>

View File

@@ -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">