This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/allura.git


The following commit(s) were added to refs/heads/master by this push:
     new 3c975226b [#11645] Force /auth/logout to go through a POST request
3c975226b is described below

commit 3c975226bfdef8801a99c391235f300303226c67
Author: Carlos Cruz <[email protected]>
AuthorDate: Wed May 27 18:40:58 2026 +0000

    [#11645] Force /auth/logout to go through a POST request
---
 Allura/allura/controllers/auth.py           | 18 ++++++---
 Allura/allura/model/auth.py                 |  2 +-
 Allura/allura/public/nf/js/allura-base.js   | 37 +++++++++++++++++++
 Allura/allura/templates/logout.html         | 51 ++++++++++++++++++++++++++
 Allura/allura/tests/functional/test_auth.py | 57 ++++++++++++++++++++++++-----
 5 files changed, 149 insertions(+), 16 deletions(-)

diff --git a/Allura/allura/controllers/auth.py 
b/Allura/allura/controllers/auth.py
index 2db61175c..e2fec3254 100644
--- a/Allura/allura/controllers/auth.py
+++ b/Allura/allura/controllers/auth.py
@@ -381,12 +381,20 @@ def verify_addr(self, a):
         self._verify_addr(addr)
         redirect('/auth/preferences/')
 
-    @expose()
+    @expose('jinja:allura:templates/logout.html')
     def logout(self, return_to=None):
-        plugin.AuthenticationProvider.get(request).logout()
-        if return_to:
-            redirect(self._verify_return_to(return_to))
-        redirect(config.get('auth.post_logout_url', '/'))
+        if request.method == 'POST':
+            plugin.AuthenticationProvider.get(request).logout()
+            if return_to:
+                redirect(self._verify_return_to(return_to))
+            redirect(config.get('auth.post_logout_url', '/'))
+        if request.method == 'GET':
+            if c.user.is_anonymous():
+                redirect(config.get('auth.post_logout_url', '/'))
+            return {
+                'return_to': self._verify_return_to(return_to) if return_to 
else None,
+            }
+        raise wexc.HTTPMethodNotAllowed(headers={'Allow': 'GET, POST'})
 
     @staticmethod
     def _verify_return_to(return_to: str | None) -> str:
diff --git a/Allura/allura/model/auth.py b/Allura/allura/model/auth.py
index 8fdbcc600..87f970189 100644
--- a/Allura/allura/model/auth.py
+++ b/Allura/allura/model/auth.py
@@ -471,7 +471,7 @@ def make_password_reset_url(self):
         reset_url = h.absurl(f'/auth/forgotten_password/{hash}')
         return reset_url
 
-    def send_email_auth_code(self, return_to='/', subject_tmpl='{site_name} 
Authentication Link', login_details=None):
+    def send_email_auth_code(self, return_to='/', subject_tmpl='{site_name} 
Authentication Code', login_details=None):
         from allura.controllers.auth import AuthController
 
         email_address = self.get_pref('email_address')
diff --git a/Allura/allura/public/nf/js/allura-base.js 
b/Allura/allura/public/nf/js/allura-base.js
index 26abee4b4..a9109b549 100644
--- a/Allura/allura/public/nf/js/allura-base.js
+++ b/Allura/allura/public/nf/js/allura-base.js
@@ -17,6 +17,43 @@
        under the License.
 */
 
+(function($) {
+    if (!window.SF) {
+        window.SF = {};
+    }
+    if (window.SF.logoutLinkHandlerInstalled) {
+        return;
+    }
+    window.SF.logoutLinkHandlerInstalled = true;
+
+    function getCookie(name) {
+        var match = document.cookie.match(new RegExp('(?:^|; )' + name + 
'=([^;]*)'));
+        return match ? decodeURIComponent(match[1]) : '';
+    }
+
+    $(document).on('click', 'a[href]', function(e) {
+        if (e.isDefaultPrevented() || e.which && e.which !== 1 || e.metaKey || 
e.ctrlKey || e.shiftKey || e.altKey) {
+            return;
+        }
+        var url;
+        try {
+            url = new URL(this.href, window.location.href);
+        } catch (err) {
+            return;
+        }
+        if (url.origin !== window.location.origin || 
url.pathname.replace(/\/$/, '') !== '/auth/logout') {
+            return;
+        }
+        e.preventDefault();
+
+        var form = $('<form>', {method: 'post', action: '/auth/logout' + 
url.search});
+        form.append($('<input type="hidden" name="_csrf_token">').val($.cookie 
? $.cookie('_csrf_token') : getCookie('_csrf_token')));
+        $('body').append(form);
+        form[0].submit();
+        form.remove();
+    });
+})(jQuery);
+
 (function($) {
     // Setup editable widgets
     $('div.editable, span.editable, h1.editable')
diff --git a/Allura/allura/templates/logout.html 
b/Allura/allura/templates/logout.html
new file mode 100644
index 000000000..3499cf481
--- /dev/null
+++ b/Allura/allura/templates/logout.html
@@ -0,0 +1,51 @@
+{#-
+       Licensed to the Apache Software Foundation (ASF) under one
+       or more contributor license agreements.  See the NOTICE file
+       distributed with this work for additional information
+       regarding copyright ownership.  The ASF licenses this file
+       to you under the Apache License, Version 2.0 (the
+       "License"); you may not use this file except in compliance
+       with the License.  You may obtain a copy of the License at
+
+         http://www.apache.org/licenses/LICENSE-2.0
+
+       Unless required by applicable law or agreed to in writing,
+       software distributed under the License is distributed on an
+       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+       KIND, either express or implied.  See the License for the
+       specific language governing permissions and limitations
+       under the License.
+-#}
+{% set hide_left_bar = True %}
+{% extends g.theme.master %}
+
+{% block title %}{{ config['site_name'] }} Sign Out{% endblock %}
+
+{% block header_classes %} logout-confirm-hidden-header{% endblock %}
+{% block header %}{% endblock %}
+{% block page_body_classes %} logout-confirm-page-body{% endblock %}
+
+{% block content %}
+{% set signed_in_username = c.user.username if c.user and not 
c.user.is_anonymous() else 'Not signed in' %}
+<div class="logout-confirm-page">
+  <div class="logout-confirm-panel">
+    <h2 class="logout-confirm-title">Are you sure you want to sign out?</h2>
+    <form class="logout-account-card" method="post" action="/auth/logout">
+      <div class="logout-avatar">
+        {% if c.user and not c.user.is_anonymous() %}
+          {{ lib.gravatar(c.user, size=40) }}
+        {% endif %}
+      </div>
+      <div class="logout-account-copy">
+        <div class="logout-account-label">Signed in as</div>
+        <div class="logout-account-name">{{ signed_in_username }}</div>
+      </div>
+      {% if return_to %}
+        <input type="hidden" name="return_to" value="{{ return_to }}">
+      {% endif %}
+      {{ lib.csrf_token() }}
+      <button class="logout-signout-button" type="submit">Sign out</button>
+    </form>
+  </div>
+</div>
+{% endblock %}
diff --git a/Allura/allura/tests/functional/test_auth.py 
b/Allura/allura/tests/functional/test_auth.py
index d323137bf..f883b65fd 100644
--- a/Allura/allura/tests/functional/test_auth.py
+++ b/Allura/allura/tests/functional/test_auth.py
@@ -61,6 +61,15 @@ def login(app, username='test-user', pwd='foo', 
query_string=''):
     return f.submit(extra_environ={'username': '*anonymous'})
 
 
+def logout(app, extra_environ=None, return_to=None, status=302):
+    if '_csrf_token' not in app.cookies:
+        app.get('/', extra_environ=extra_environ or {}).maybe_follow()
+    params = {'_csrf_token': app.cookies['_csrf_token']}
+    if return_to is not None:
+        params['return_to'] = return_to
+    return app.post('/auth/logout', params=params, 
extra_environ=extra_environ, status=status)
+
+
 class TestAuth(TestController):
     def test_login(self):
         self.app.get('/auth/preferences/')  # establish _csrf_token cookie
@@ -177,7 +186,7 @@ def 
test_login_hibp_compromised_password_trusted_client(self, sendsimplemail):
         f[encoded['password']] = 'foo'
         with audits('Successful login', user=True):
             f.submit(status=302)
-        self.app.get('/auth/logout')
+        logout(self.app)
 
         # this login will get caught by HIBP check, but trusted due to IP 
address being same
         with 
patch('allura.lib.plugin.AuthenticationProvider.hibp_password_check_enabled', 
Mock(return_value=True)):
@@ -296,6 +305,9 @@ def test_logout(self):
         nav_pattern = ('nav', {'class': 'nav-main'})
         r = self.app.get('/auth/')
 
+        r = self.app.get('/auth/logout', status=302)
+        assert r.location == 'http://localhost/'
+
         r = self.app.post('/auth/do_login', params=dict(
             username='test-user', password='foo',
             _csrf_token=self.app.cookies['_csrf_token']),
@@ -307,6 +319,31 @@ def test_logout(self):
         assert links[-1].string == "Log Out"
 
         r = self.app.get('/auth/logout')
+        assert 'Clear-Site-Data' not in r.headers
+        assert r.session['_id'] == logged_in_session
+        assert 'Are you sure you want to sign out?' in r
+        assert 'Signed in as' in r
+        assert 'test-user' in r
+        assert r.forms[0].action.endswith('/auth/logout')
+        assert r.forms[0].method.upper() == 'POST'
+        assert r.forms[0]['_csrf_token'].value
+
+        r = self.app.get('/auth/logout', params={'return_to': '/foo'})
+        assert r.forms[0]['return_to'].value == '/foo'
+        r = self.app.get('/auth/logout', params={'return_to': 
'http://example.com/foo'})
+        assert r.forms[0]['return_to'].value == '/'
+
+        self.app.put('/auth/logout', status=405)
+        self.app.post('/auth/logout', params={}, status=403)
+        self.app.post('/auth/logout', params={'_csrf_token': 'bogus'}, 
status=403)
+
+        r = logout(self.app, return_to='/foo')
+        assert r.location == 'http://localhost/foo'
+
+        r = login(self.app).follow().follow()
+        logged_in_session = r.session['_id']
+
+        r = logout(self.app)
         assert 'Clear-Site-Data' in r.headers
 
         r = r.follow().follow()
@@ -1102,7 +1139,7 @@ def test_create_account(self):
                 _csrf_token=self.app.cookies['_csrf_token'],
             ))
         assert 'That username is already taken. Please choose another.' in r
-        r = self.app.get('/auth/logout')
+        r = logout(self.app)
         r = self.app.post(
             '/auth/do_login',
             params=dict(username='aaa', password='12345678',
@@ -1245,7 +1282,7 @@ def test_disabled_user(self):
         assert r.location == 
'http://localhost/auth/?return_to=%2Fp%2Ftest%2Fadmin%2F'
 
     def test_no_open_return_to(self):
-        r = self.app.get('/auth/logout').follow().follow()
+        r = logout(self.app).follow().follow()
         r = self.app.post('/auth/do_login', params=dict(
             username='test-user', password='foo',
             return_to='/foo',
@@ -1254,21 +1291,21 @@ def test_no_open_return_to(self):
         )
         assert r.location == 'http://localhost/foo'
 
-        r = self.app.get('/auth/logout')
+        r = logout(self.app)
         r = self.app.post('/auth/do_login', antispam=True, params=dict(
             username='test-user', password='foo',
             return_to='http://localhost/foo',
             _csrf_token=self.app.cookies['_csrf_token']))
         assert r.location == 'http://localhost/foo'
 
-        r = self.app.get('/auth/logout')
+        r = logout(self.app)
         r = self.app.post('/auth/do_login', antispam=True, params=dict(
             username='test-user', password='foo',
             return_to='http://example.com/foo',
             _csrf_token=self.app.cookies['_csrf_token'])).follow()
         assert r.location == 'http://localhost/dashboard'
 
-        r = self.app.get('/auth/logout')
+        r = logout(self.app)
         r = self.app.post('/auth/do_login', antispam=True, params=dict(
             username='test-user', password='foo',
             return_to='//example.com/foo',
@@ -1276,7 +1313,7 @@ def test_no_open_return_to(self):
         assert r.location == 'http://localhost/dashboard'
 
     def test_no_injected_headers_in_return_to(self):
-        r = self.app.get('/auth/logout').follow().follow()
+        r = logout(self.app).follow().follow()
         r = self.app.post('/auth/do_login', params=dict(
             username='test-user', password='foo',
             return_to='/foo\nContent-Length: 777',
@@ -2886,7 +2923,7 @@ def test_logout(self):
             r = login(self.app)
             assert self.expired(r)
             self.assert_redirects()
-            r = self.app.get('/auth/logout', extra_environ={'username': 
'test-user'})
+            r = logout(self.app, extra_environ={'username': 'test-user'})
             assert not self.expired(r)
             self.assert_not_redirects()
 
@@ -3612,7 +3649,7 @@ def test_email_auth_code(self, send_system_mail_to_user):
             # Validate the email with the verification link was sent
             args, kwargs = send_system_mail_to_user.call_args
             assert r.session.get('mode') == 'email_code'
-            assert args[1] == f"{config['site_name']} Authentication Link"
+            assert args[1] == f"{config['site_name']} Authentication Code"
             assert send_system_mail_to_user.call_count == 1
             email_text = send_system_mail_to_user.call_args[0][2]
 
@@ -3694,7 +3731,7 @@ def test_untrack_user_session(self):
         session_ids = user.get_tool_data('web_session', 'ids')
         assert len(session_ids) == 1
 
-        r = self.app.get('/auth/logout', extra_environ={'username': 
'test-user'})
+        r = logout(self.app, extra_environ={'username': 'test-user'})
         user = M.User.by_username('test-user')
         session_ids = user.get_tool_data('web_session', 'ids')
         assert len(session_ids) == 0

Reply via email to