cockpit: Reconfigure to allow any origin

When Cockpit is not configured any origins, it uses the host and protocol of the
incoming request to set the allowed origin for WebSocket connections. By
ensuring that the original host/protocol is passed on to Cockpit from the
browser, we can eliminate the need for configuring a pre-determined list of
origins. Passing the host and protocol from the browser is done by setting
ProxyPreserveHost and using https:// for proxying.

For a cross-site request, Origin: and Host: entries won't match and '403
Forbidden' is thrown. So, this approach is still safe.

Tests:

- Without the patch, access Cockpit using IP address and it fails. Apply the
patch. Cockpit setup should run. Origins= directive in the configuration file
/etc/cockpit/cockpit.conf should get removed. Accessing with IP address and
logging in succeeds.

- Freshly setup a container with the patch and access Cockpit using IP address.
This works and login succeeds.

- Test on stable and testing containers.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Sunil Mohan Adapa 2022-07-06 14:51:49 -07:00 committed by James Valleroy
parent 372ecdcda9
commit c163601b6c
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
7 changed files with 39 additions and 180 deletions

View File

@ -6,8 +6,11 @@ Configuration helper for Cockpit.
import argparse
import augeas
from plinth import action_utils
from plinth.modules.cockpit import utils
CONFIG_FILE = '/etc/cockpit/cockpit.conf'
def parse_arguments():
@ -15,65 +18,31 @@ def parse_arguments():
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
subparser = subparsers.add_parser('setup',
help='Setup Cockpit configuration')
subparser.add_argument('domain_names', nargs='*',
help='Domain names to be allowed')
subparser = subparsers.add_parser(
'add-domain',
help='Allow a new domain to be origin for Cockpit\'s WebSocket')
subparser.add_argument('domain_name', help='Domain name to be allowed')
subparser = subparsers.add_parser(
'remove-domain',
help='Disallow a new domain from being origin for Cockpit\'s '
'WebSocket')
subparser.add_argument('domain_name', help='Domain name to be removed')
subparsers.add_parser('setup', help='Setup Cockpit configuration')
subparsers.required = True
return parser.parse_args()
def _load_augeas():
"""Initialize Augeas."""
aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD +
augeas.Augeas.NO_MODL_AUTOLOAD)
aug.set('/augeas/load/inifile/lens', 'Puppet.lns')
aug.set('/augeas/load/inifile/incl[last() + 1]', CONFIG_FILE)
aug.load()
return aug
def subcommand_setup(arguments):
"""Setup Cockpit configuration."""
aug = utils.load_augeas()
origins = [
utils.get_origin_from_domain(domain)
for domain in arguments.domain_names
]
origins += ['https://localhost', 'https://localhost:4430']
_set_origin_domains(aug, origins)
aug.set('/files' + utils.CONFIG_FILE + '/WebService/UrlRoot', '/_cockpit/')
aug = _load_augeas()
aug.set('/files' + CONFIG_FILE + '/WebService/UrlRoot', '/_cockpit/')
aug.remove('/files' + CONFIG_FILE + '/WebService/Origins')
aug.save()
action_utils.service_restart('cockpit.socket')
def _set_origin_domains(aug, origins):
"""Set the list of allowed origin domains."""
aug.set('/files' + utils.CONFIG_FILE + '/WebService/Origins',
' '.join(origins))
def subcommand_add_domain(arguments):
"""Allow a new domain to be origin for Cockpit's WebSocket."""
aug = utils.load_augeas()
origins = utils.get_origin_domains(aug)
origins.add(utils.get_origin_from_domain(arguments.domain_name))
_set_origin_domains(aug, origins)
aug.save()
def subcommand_remove_domain(arguments):
"""Disallow a domain from being origin for Cockpit's WebSocket."""
aug = utils.load_augeas()
origins = utils.get_origin_domains(aug)
try:
origins.remove(utils.get_origin_from_domain(arguments.domain_name))
except KeyError:
pass
else:
_set_origin_domains(aug, origins)
aug.save()
# Accommodate changes in Apache configuration file from v1 to v2.
action_utils.service_reload('apache2')
def main():

View File

@ -10,15 +10,13 @@ from plinth import actions
from plinth import app as app_module
from plinth import cfg, frontpage, menu
from plinth.daemon import Daemon
from plinth.modules import names
from plinth.modules.apache.components import Webserver
from plinth.modules.backups.components import BackupRestore
from plinth.modules.firewall.components import Firewall
from plinth.package import Packages
from plinth.signals import domain_added, domain_removed
from plinth.utils import format_lazy
from . import manifest, utils
from . import manifest
_description = [
format_lazy(
@ -36,10 +34,6 @@ _description = [
_('It can be accessed by <a href="{users_url}">any user</a> on '
'{box_name} belonging to the admin group.'),
box_name=_(cfg.box_name), users_url=reverse_lazy('users:index')),
format_lazy(
_('Cockpit requires that you access it through a domain name. '
'It will not work when accessed using an IP address as part'
' of the URL.')),
]
app = None
@ -50,9 +44,7 @@ class CockpitApp(app_module.App):
app_id = 'cockpit'
_version = 1
DAEMON = 'cockpit.socket'
_version = 2
def __init__(self):
"""Create components for the app."""
@ -91,43 +83,17 @@ class CockpitApp(app_module.App):
urls=['https://{host}/_cockpit/'])
self.add(webserver)
daemon = Daemon('daemon-cockpit', self.DAEMON)
daemon = Daemon('daemon-cockpit', 'cockpit.socket')
self.add(daemon)
backup_restore = BackupRestore('backup-restore-cockpit',
**manifest.backup)
self.add(backup_restore)
@staticmethod
def post_init():
"""Perform post initialization operations."""
domain_added.connect(on_domain_added)
domain_removed.connect(on_domain_removed)
def setup(helper, old_version=None):
"""Install and configure the module."""
app.setup(old_version)
domains = names.components.DomainName.list_names('https')
helper.call('post', actions.superuser_run, 'cockpit',
['setup'] + list(domains))
helper.call('post', app.enable)
def on_domain_added(sender, domain_type, name='', description='',
services=None, **kwargs):
"""Handle addition of a new domain."""
if name and not app.needs_setup():
if name not in utils.get_domains():
actions.superuser_run('cockpit', ['add-domain', name])
actions.superuser_run('service',
['try-restart', CockpitApp.DAEMON])
def on_domain_removed(sender, domain_type, name='', **kwargs):
"""Handle removal of a domain."""
if name and not app.needs_setup():
if name in utils.get_domains():
actions.superuser_run('cockpit', ['remove-domain', name])
actions.superuser_run('service',
['try-restart', CockpitApp.DAEMON])
helper.call('post', actions.superuser_run, 'cockpit', ['setup'])
if not old_version:
helper.call('post', app.enable)

View File

@ -14,9 +14,17 @@
ReWriteCond %{HTTPS} !=on
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=302,L]
ProxyPass http://localhost:9090/_cockpit/
ProxyPass https://localhost:9090/_cockpit/
ProxyPreserveHost On
</Location>
<Location /_cockpit/cockpit/socket>
ProxyPass ws://localhost:9090/_cockpit/socket
ProxyPass wss://localhost:9090/_cockpit/socket
ProxyPreserveHost On
</Location>
<ProxyMatch "^(https|wss)://localhost:9090/_cockpit/.*">
SSLProxyEngine on
SSLProxyVerify none
SSLProxyCheckPeerName off
</ProxyMatch>

View File

@ -1,24 +0,0 @@
{% extends "app.html" %}
{% comment %}
# SPDX-License-Identifier: AGPL-3.0-or-later
{% endcomment %}
{% load bootstrap %}
{% load i18n %}
{% block status %}
{{ block.super }}
<h3>{% trans "Access" %}</h3>
<p>
{% blocktrans trimmed %}
Cockpit will only work when accessed using the following URLs.
{% endblocktrans %}
</p>
<ol>
{% for url_ in urls %}
<li>{{ url_ }}</li>
{% endfor %}
</ol>
{% endblock %}

View File

@ -5,8 +5,9 @@ URLs for Cockpit module.
from django.urls import re_path
from plinth.modules.cockpit.views import CockpitAppView
from plinth.views import AppView
urlpatterns = [
re_path(r'^sys/cockpit/$', CockpitAppView.as_view(), name='index'),
re_path(r'^sys/cockpit/$', AppView.as_view(app_id='cockpit'),
name='index'),
]

View File

@ -1,43 +0,0 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Minor utility methods for Cockpit.
"""
import urllib.parse
import augeas
CONFIG_FILE = '/etc/cockpit/cockpit.conf'
def load_augeas():
"""Initialize Augeas."""
aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD +
augeas.Augeas.NO_MODL_AUTOLOAD)
aug.set('/augeas/load/inifile/lens', 'Puppet.lns')
aug.set('/augeas/load/inifile/incl[last() + 1]', CONFIG_FILE)
aug.load()
return aug
def get_origin_domains(aug):
"""Return the list of allowed origin domains."""
origins = aug.get('/files' + CONFIG_FILE + '/WebService/Origins')
return set(origins.split()) if origins else set()
def get_origin_from_domain(domain):
"""Return the origin that should be allowed for a domain."""
return 'https://{domain}'.format(domain=domain)
def _get_domain_from_origin(origin):
"""Return the domain from an origin URL."""
return urllib.parse.urlparse(origin).netloc
def get_domains():
"""Return the domain name in origin URL."""
aug = load_augeas()
origins = get_origin_domains(aug)
return [_get_domain_from_origin(origin) for origin in origins]

View File

@ -1,18 +0,0 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Views for the Cockpit module
"""
from plinth.modules.cockpit.utils import get_origin_domains, load_augeas
from plinth.views import AppView
class CockpitAppView(AppView):
app_id = 'cockpit'
template_name = 'cockpit.html'
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(**kwargs)
urls = get_origin_domains(load_augeas())
context['urls'] = [url for url in urls if 'localhost' not in url]
return context