Fixes #23112: Initiate SSO logins via a script-driven navigation (#23177)

Rendering the SSO buttons as POST forms (#23042) made every SSO login a form
submission which NetBox answers with a redirect to the identity provider.
Chromium-based browsers evaluate the CSP form-action directive against every hop
in a form submission's redirect chain, so a deployment which serves NetBox with
`form-action 'self'` blocks that redirect and the button silently does nothing.

Add SocialAuthBeginView, which wraps python-social-auth's begin view and returns
the identity provider's URL as JSON to clients which request it. The login page
now submits the form via fetch() and assigns window.location, which form-action
does not govern. The upstream view is reused as-is, so CSRF protection, the
callback URL, and the session state recorded for the identity provider are
unchanged; clients which do not request JSON (a browser without JavaScript, or a
backend which renders an HTML form rather than redirecting) receive the
unmodified response as before.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeremy Stretch 2026-09-15 13:00:11 -04:00 committed by GitHub
parent e15b3d9080
commit a4ade2e7ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 351 additions and 15 deletions

View File

@ -41,6 +41,12 @@ NetBox supports single sign-on authentication via the [python-social-auth](https
Most remote authentication backends require some additional configuration through settings prefixed with `SOCIAL_AUTH_`. These will be automatically imported from NetBox's `configuration.py` file. Additionally, the [authentication pipeline](https://python-social-auth.readthedocs.io/en/latest/pipeline.html) can be customized via the `SOCIAL_AUTH_PIPELINE` parameter. (NetBox's default pipeline is defined in `netbox/settings.py` for your reference.)
!!! note "Content Security Policy"
Beginning an SSO login requires the browser to make a request back to NetBox before it is sent
on to the identity provider. If you serve NetBox with a Content Security Policy which does not
permit same-origin connections, SSO logins will fail: add `connect-src 'self'` (or a
`default-src` which covers it) to your policy.
#### Configuring the SSO module's appearance
The way a remote authentication backend is displayed to the user on the login

View File

@ -9,14 +9,16 @@ from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import update_last_login
from django.contrib.auth.signals import user_logged_in
from django.http import HttpResponseRedirect
from django.http import HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render, resolve_url
from django.urls import reverse, reverse_lazy
from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _
from django.views.decorators.cache import never_cache
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import View
from social_core.backends.utils import load_backends
from social_django.views import auth as social_auth_begin
from account.models import UserToken
from core.models import ObjectChange
@ -78,7 +80,7 @@ class LoginView(View):
request_data = request.POST if request.method == 'POST' else request.GET
for name in load_backends(settings.AUTHENTICATION_BACKENDS).keys():
url = reverse('social:begin', args=[name])
url = reverse('social_auth_begin', args=[name])
params = {}
if next := request_data.get('next'):
params['next'] = next
@ -188,6 +190,50 @@ class LogoutView(View):
return response
class SocialAuthBeginView(View):
"""
Initiate authentication against a social auth (SSO) backend.
This wraps python-social-auth's "begin" view, which responds with an HTTP redirect to the
identity provider. Chromium-based browsers evaluate the CSP `form-action` directive against
every hop in a form submission's redirect chain, so a deployment which serves NetBox with
`form-action 'self'` (a common reverse proxy default) blocks that redirect and the SSO button
appears to do nothing. A client which asks for JSON is given the identity provider's URL in the
response body instead, and navigates to it itself: `form-action` does not govern a navigation
initiated by a script. Any other client (e.g. a browser with JavaScript disabled) receives the
unmodified response from python-social-auth.
A backend which does not redirect (`uses_redirect()` is False, as for OpenID 2.0) renders its
own HTML instead, which is returned in the response body for the client to render in place so
that it need not repeat the request. This is not a way around `form-action`: that document
carries a form which submits itself to the identity provider, and such a submission is governed
by the policy wherever the document is rendered. Deployments using one of these backends still
require a `form-action` which admits the identity provider.
The underlying view is reused as-is so that CSRF protection, the callback URL, and the session
state recorded for the identity provider all remain identical to a direct form submission.
"""
@method_decorator(never_cache)
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
def post(self, request, backend):
response = social_auth_begin(request, backend)
if 'application/json' in request.headers.get('Accept', ''):
if url := response.headers.get('Location'):
return JsonResponse({'url': url})
if response.status_code == 200:
# Some backends render an HTML form (which submits itself to the identity provider)
# rather than redirecting. Hand that document to the client to render, so that it
# need not repeat the request and initiate the login a second time.
return JsonResponse({'html': response.content.decode(response.charset)})
# Anything else (including the response to a client which has not asked for JSON) is passed
# through unchanged.
return response
#
# User profiles
#

View File

@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch
from django.conf import settings
from django.contrib.messages.storage.fallback import FallbackStorage
from django.http import HttpResponse
from django.test import Client, RequestFactory, SimpleTestCase
from django.test import TestCase as DjangoTestCase
from django.test.utils import override_settings
@ -861,7 +862,7 @@ class SSOLoginButtonTestCase(DjangoTestCase):
Return the body of the rendered SSO form. The password login form renders its own hidden
`next` field, so assertions about the SSO parameters must be scoped to this form.
"""
begin_url = reverse('social:begin', args=['google-oauth2'])
begin_url = reverse('social_auth_begin', args=['google-oauth2'])
match = re.search(
rf'<form[^>]*action="{re.escape(begin_url)}"[^>]*>(.*?)</form>',
response.content.decode(),
@ -876,7 +877,7 @@ class SSOLoginButtonTestCase(DjangoTestCase):
"""
Each SSO button must be rendered as a POST form (including a CSRF token) rather than a link.
"""
begin_url = reverse('social:begin', args=['google-oauth2'])
begin_url = reverse('social_auth_begin', args=['google-oauth2'])
response = self.client.get(reverse('login'))
self.assertEqual(response.status_code, 200)
@ -951,6 +952,158 @@ class SSOLoginButtonTestCase(DjangoTestCase):
self.assertEqual(auth_backend['params'].get('next'), '/dcim/sites/')
class SocialAuthBeginViewTestCase(DjangoTestCase):
"""
Verify the view which initiates an SSO login. Chromium-based browsers evaluate the CSP
`form-action` directive against every hop in a form submission's redirect chain, so redirecting
the submission to the identity provider is blocked wherever `form-action 'self'` is enforced.
Clients which ask for JSON are handed the identity provider's URL to navigate to instead
(see #23112).
"""
SSO_BACKENDS = [
'social_core.backends.google.GoogleOAuth2',
'netbox.authentication.ObjectPermissionBackend',
]
AUTHORIZATION_URL = 'https://accounts.google.com/o/oauth2/auth'
# Stands in for the document rendered by a backend which does not redirect (see BaseAuth.start())
AUTH_HTML = '<html><body><form id="openid_message" action="https://idp.example.com/"></form></body></html>'
def setUp(self):
# load_backends() caches the discovered backends in a module-level dict, so isolate the
# backends overridden below from the remainder of the test suite.
cache_patcher = patch.dict('social_core.backends.utils.BACKENDSCACHE', {}, clear=True)
cache_patcher.start()
self.addCleanup(cache_patcher.stop)
self.url = reverse('social_auth_begin', args=['google-oauth2'])
@override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
def test_json_request_returns_authorization_url(self):
"""
A client which requests JSON receives the identity provider's URL in the response body
rather than an HTTP redirect, so that it can navigate there itself.
"""
response = self.client.post(self.url, headers={'accept': 'application/json'})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers['Content-Type'], 'application/json')
self.assertNotIn('Location', response.headers)
self.assertTrue(response.json()['url'].startswith(self.AUTHORIZATION_URL))
@override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
def test_form_submission_returns_redirect(self):
"""
A client which has not asked for JSON (e.g. a browser with JavaScript disabled) receives the
unmodified redirect from python-social-auth.
"""
response = self.client.post(self.url, headers={'accept': 'text/html'})
self.assertEqual(response.status_code, 302)
self.assertTrue(response.headers['Location'].startswith(self.AUTHORIZATION_URL))
@override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
def test_session_state_recorded(self):
"""
The anti-forgery state conveyed to the identity provider must be recorded in the session, as
the completion view compares the two. This is what makes the JSON response safe to follow:
the session established here is the one the callback is validated against.
"""
response = self.client.post(self.url, headers={'accept': 'application/json'})
state = self.client.session['google-oauth2_state']
self.assertIn(f'state={state}', response.json()['url'])
@override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
def test_next_recorded_in_session(self):
"""
The post-login URL is read from the POST data by do_auth() and stashed in the session; the
wrapper must not interfere with the form fields rendered on the login page.
"""
self.client.post(self.url, {'next': '/dcim/sites/'}, headers={'accept': 'application/json'})
self.assertEqual(self.client.session['next'], '/dcim/sites/')
@override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
def test_get_request_not_allowed(self):
"""
Authentication must be initiated by POST: a GET request is trivially forgeable, which is why
social-auth-app-django restricts its own begin view to POST.
"""
response = self.client.get(self.url)
self.assertEqual(response.status_code, 405)
@override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
def test_csrf_token_required(self):
"""
CSRF protection must be retained, so that a third party cannot silently initiate an SSO
login on the user's behalf.
"""
client = Client(enforce_csrf_checks=True)
response = client.post(self.url, headers={'accept': 'application/json'})
self.assertEqual(response.status_code, 403)
@override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
def test_response_is_not_cached(self):
"""
The authorization URL embeds a single-use state parameter and must never be cached.
"""
response = self.client.post(self.url, headers={'accept': 'application/json'})
self.assertIn('no-store', response.headers['Cache-Control'])
@override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
def test_unknown_backend(self):
"""
An unconfigured backend yields an HTTP 404, as it does via python-social-auth directly.
"""
response = self.client.post(reverse('social_auth_begin', args=['nosuchbackend']))
self.assertEqual(response.status_code, 404)
@override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
@patch('social_core.backends.google.GoogleOAuth2.uses_redirect', return_value=False)
@patch('social_core.backends.google.GoogleOAuth2.auth_html', return_value=AUTH_HTML)
def test_json_request_returns_html_for_non_redirecting_backend(self, _auth_html, _uses_redirect):
"""
A backend which renders its own HTML rather than redirecting has that document returned in
the response body. The client renders it in place: were it made to submit the form to fetch
the document again, the login would be initiated a second time.
"""
response = self.client.post(self.url, headers={'accept': 'application/json'})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers['Content-Type'], 'application/json')
self.assertNotIn('Location', response.headers)
self.assertEqual(response.json()['html'], self.AUTH_HTML)
@override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
@patch('social_core.backends.google.GoogleOAuth2.uses_redirect', return_value=False)
@patch('social_core.backends.google.GoogleOAuth2.auth_html', return_value=AUTH_HTML)
def test_html_passed_through_for_non_redirecting_backend(self, _auth_html, _uses_redirect):
"""
A client which has not asked for JSON receives that same document unmodified.
"""
response = self.client.post(self.url, headers={'accept': 'text/html'})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content.decode(), self.AUTH_HTML)
@override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
@patch('account.views.social_auth_begin', side_effect=lambda *args, **kwargs: HttpResponse(status=502))
def test_json_request_passes_through_error_response(self, _begin):
"""
Only a redirect or a rendered document is translated to JSON. An unsuccessful response is
passed through as-is, so that the client reports the failure rather than mistaking the
response for a login it can act on.
"""
response = self.client.post(self.url, headers={'accept': 'application/json'})
self.assertEqual(response.status_code, 502)
self.assertNotEqual(response.headers.get('Content-Type'), 'application/json')
class SocialAuthExceptionMiddlewareTestCase(SimpleTestCase):
"""
Verify that SSO/SAML authentication failures are surfaced as a login-page message rather than

View File

@ -4,7 +4,7 @@ from django.urls import path
from django.views.decorators.cache import cache_page
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
from account.views import LoginView, LogoutView
from account.views import LoginView, LogoutView, SocialAuthBeginView
from netbox.api.views import APIRootView, AuthenticationCheckView, StatusView
from netbox.graphql.schema import schema
from netbox.graphql.views import NetBoxGraphQLView
@ -20,6 +20,7 @@ _patterns = [
# Login/logout
path('login/', LoginView.as_view(), name='login'),
path('logout/', LogoutView.as_view(), name='logout'),
path('oauth/begin/<str:backend>/', SocialAuthBeginView.as_view(), name='social_auth_begin'),
path('oauth/', include('social_django.urls', namespace='social')),
# Apps

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -14,6 +14,7 @@ import { initRackElevation } from './racks';
import { initHtmx } from './htmx';
import { initSavedFilterSelect } from './forms/savedFiltersSelect';
import { initHotkeys } from './hotkeys';
import { initSSOForms } from './sso';
function initDocument(): void {
for (const init of [
@ -33,6 +34,7 @@ function initDocument(): void {
initHtmx,
initSavedFilterSelect,
initHotkeys,
initSSOForms,
]) {
init();
}

View File

@ -0,0 +1,124 @@
import { getElement, getElements } from './util';
// Abandon a login which has gone unanswered for this many milliseconds, so that a request which
// hangs (a backend retrieving identity provider metadata of its own, for instance) surfaces an
// error rather than leaving the SSO buttons disabled indefinitely.
const REQUEST_TIMEOUT = 15000;
// Whether a login is already being initiated. Each login is issued a single-use state parameter,
// which the completion view compares against the value recorded in the session, so a second login
// would invalidate the state of the one being navigated to. This is scoped to the document rather
// than to the form: SAML renders one form per identity provider, and all of them write the session
// keys of the same backend.
let loginPending = false;
/**
* Enable or disable every SSO button on the page. All of them are disabled while a login is being
* initiated, as only one login can be in flight at a time.
*/
function setButtonsDisabled(disabled: boolean): void {
for (const form of getElements<HTMLFormElement>('form.sso-login-form')) {
for (const button of form.querySelectorAll<HTMLButtonElement>('button[type="submit"]')) {
button.disabled = disabled;
}
}
}
function setErrorVisible(visible: boolean): void {
const error = getElement('sso-error');
if (error === null) return;
error.classList.toggle('d-none', !visible);
if (visible) {
// Revealing an alert whose text has not itself changed is not reliably announced by a screen
// reader, so move focus to it (the login the user asked for did not begin, and the reason for
// that is the only thing worth their attention).
error.focus();
}
}
/**
* Begin an SSO login by navigating to the identity provider, rather than by submitting the form.
*
* The social auth "begin" endpoint responds with a redirect to the identity provider. Chromium-based
* browsers evaluate the CSP `form-action` directive against every hop in a form submission's
* redirect chain, so a deployment which serves NetBox with `form-action 'self'` blocks that redirect
* and the SSO button appears to do nothing. Requesting the identity provider's URL and navigating to
* it here sidesteps the directive, which does not govern a navigation initiated by a script.
*/
async function beginLogin(form: HTMLFormElement): Promise<void> {
const body = new URLSearchParams();
for (const [name, value] of new FormData(form).entries()) {
if (typeof value === 'string') {
body.append(name, value);
}
}
// Browsers predating AbortSignal.timeout() issue the request without a deadline, which is no
// worse than the behavior they had before it was imposed.
const signal =
typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(REQUEST_TIMEOUT) : null;
const res = await fetch(form.action, {
method: 'POST',
headers: { Accept: 'application/json' },
body,
credentials: 'same-origin',
signal,
});
if (!res.ok || !(res.headers.get('Content-Type') ?? '').includes('application/json')) {
throw new Error(`The login request returned an unexpected response (HTTP ${res.status})`);
}
const { url, html } = (await res.json()) as { url?: string; html?: string };
if (typeof url === 'string') {
window.location.assign(url);
} else if (typeof html === 'string') {
// The backend renders its own HTML (an auto-submitting form, for instance) instead of
// redirecting. That document has already been generated by the request above, so render it in
// place; submitting the form to fetch it again would initiate the login a second time. Note
// that this does not evade `form-action`: the document is written into NetBox's own, so the
// form it carries is submitted under NetBox's policy exactly as it would have been otherwise.
document.open();
document.write(html);
document.close();
} else {
throw new Error('The login response contained neither a URL nor a document');
}
}
export function initSSOForms(): void {
for (const form of getElements<HTMLFormElement>('form.sso-login-form')) {
form.addEventListener('submit', event => {
event.preventDefault();
if (loginPending) return;
loginPending = true;
setErrorVisible(false);
setButtonsDisabled(true);
beginLogin(form).catch(error => {
// Report the failure rather than falling back to submitting the form: a deployment which
// serves NetBox with `form-action 'self'` — the very condition this indirection exists to
// work around — blocks that submission silently, leaving a dead button and no explanation.
// The alert shown to the user cannot say why the login failed, so log the reason.
console.error(error);
loginPending = false;
setButtonsDisabled(false);
setErrorVisible(true);
});
});
}
// A browser which restores the login page from its back/forward cache (having navigated to the
// identity provider and returned) preserves both the DOM and the state above, which would
// otherwise leave every SSO button permanently disabled. Only a restore may clear the guard:
// pageshow also fires on an ordinary load, after `load` and so after a login begun in the
// meantime, where resetting it would readmit the very double-submission it exists to prevent.
window.addEventListener('pageshow', event => {
if (!event.persisted) return;
loginPending = false;
setButtonsDisabled(false);
});
}

View File

@ -83,11 +83,15 @@
{% if login_form_hidden %}
<h2 class="text-center mb-4">{% trans "Log In" %}</h2>
{% endif %}
{# Revealed and focused by initSSOForms() when a login cannot be initiated #}
<div id="sso-error" class="alert alert-danger d-none" role="alert" tabindex="-1">
{% trans "Unable to begin single sign-on. Please try again." %}
</div>
<div class="row">
{% for backend in auth_backends %}
<div class="col">
{# The social auth begin view accepts only POST requests #}
<form action="{{ backend.url }}" method="post">
{# SSO logins are initiated by POST; see SocialAuthBeginView #}
<form action="{{ backend.url }}" method="post" class="sso-login-form">
{% csrf_token %}
{% for param, value in backend.params.items %}
<input type="hidden" name="{{ param }}" value="{{ value }}" />