Răsfoiți Sursa

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>
Jeremy Stretch 7 ore în urmă
părinte
comite
a4ade2e7ae

+ 6 - 0
docs/administration/authentication/overview.md

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

+ 48 - 2
netbox/account/views.py

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

+ 155 - 2
netbox/netbox/tests/test_authentication.py

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

+ 2 - 1
netbox/netbox/urls.py

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

Fișier diff suprimat deoarece este prea mare
+ 0 - 0
netbox/project-static/dist/netbox.js


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 2 - 0
netbox/project-static/src/netbox.ts

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

+ 124 - 0
netbox/project-static/src/sso.ts

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

+ 6 - 2
netbox/templates/login.html

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

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff