From b8aa6ecd705df37dd98d3b0d368330c1c7cd1169 Mon Sep 17 00:00:00 2001 From: Matthias Bussonnier Date: Fri, 21 Jul 2017 10:23:54 -0700 Subject: [PATCH 01/17] Remove unused import and add version to deprecations. --- jupyterhub/app.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/jupyterhub/app.py b/jupyterhub/app.py index df7c6d0d..3fd58063 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -13,8 +13,6 @@ import os import re import shutil import signal -import socket -from subprocess import Popen import sys from textwrap import dedent import threading @@ -357,14 +355,14 @@ class JupyterHub(Application): ).tag(config=True) proxy_cmd = Command([], config=True, - help="DEPRECATED. Use ConfigurableHTTPProxy.command", + help="DEPRECATED since version 0.8. Use ConfigurableHTTPProxy.command", ).tag(config=True) debug_proxy = Bool(False, - help="DEPRECATED: Use ConfigurableHTTPProxy.debug", + help="DEPRECATED since version 0.8: Use ConfigurableHTTPProxy.debug", ).tag(config=True) proxy_auth_token = Unicode( - help="DEPRECATED: Use ConfigurableHTTPProxy.auth_token" + help="DEPRECATED since version 0.8: Use ConfigurableHTTPProxy.auth_token" ).tag(config=True) _proxy_config_map = { @@ -379,10 +377,10 @@ class JupyterHub(Application): self.config.ConfigurableHTTPProxy[dest] = change.new proxy_api_ip = Unicode( - help="DEPRECATED: Use ConfigurableHTTPProxy.api_url" + help="DEPRECATED since version 0.8 : Use ConfigurableHTTPProxy.api_url" ).tag(config=True) proxy_api_port = Integer( - help="DEPRECATED: Use ConfigurableHTTPProxy.api_url" + help="DEPRECATED since version 0.8 : Use ConfigurableHTTPProxy.api_url" ).tag(config=True) @observe('proxy_api_port', 'proxy_api_ip') def _deprecated_proxy_api(self, change): @@ -464,7 +462,8 @@ class JupyterHub(Application): @observe('api_tokens') def _deprecate_api_tokens(self, change): - self.log.warning("JupyterHub.api_tokens is pending deprecation." + self.log.warning("JupyterHub.api_tokens is pending deprecations" + " since JupyterHub version 0.8." " Consider using JupyterHub.service_tokens." " If you have a use case for services that identify as users," " let us know: https://github.com/jupyterhub/jupyterhub/issues" @@ -572,7 +571,7 @@ class JupyterHub(Application): """ ).tag(config=True) admin_users = Set( - help="""DEPRECATED, use Authenticator.admin_users instead.""" + help="""DEPRECATED since version 0.7.2, use Authenticator.admin_users instead.""" ).tag(config=True) tornado_settings = Dict( @@ -862,7 +861,7 @@ class JupyterHub(Application): if self.admin_users and not self.authenticator.admin_users: self.log.warning( - "\nJupyterHub.admin_users is deprecated." + "\nJupyterHub.admin_users is deprecated since version 0.7.2." "\nUse Authenticator.admin_users instead." ) self.authenticator.admin_users = self.admin_users @@ -1163,8 +1162,7 @@ class JupyterHub(Application): self.oauth_provider = make_provider( self.session_factory, url_prefix=url_path_join(base_url, 'api/oauth2'), - login_url=url_path_join(base_url, 'login') -, + login_url=url_path_join(base_url, 'login'), ) def init_proxy(self): From 97b9c4899a65dfe104d25b162a5c4c4f08506928 Mon Sep 17 00:00:00 2001 From: Matthias Bussonnier Date: Fri, 21 Jul 2017 11:12:24 -0700 Subject: [PATCH 02/17] typo --- jupyterhub/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 3fd58063..d3b66bcc 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -462,7 +462,7 @@ class JupyterHub(Application): @observe('api_tokens') def _deprecate_api_tokens(self, change): - self.log.warning("JupyterHub.api_tokens is pending deprecations" + self.log.warning("JupyterHub.api_tokens is pending deprecation" " since JupyterHub version 0.8." " Consider using JupyterHub.service_tokens." " If you have a use case for services that identify as users," From e1444f4aca0071a4d166af81d10e8286b2fdc84f Mon Sep 17 00:00:00 2001 From: Matthias Bussonnier Date: Fri, 21 Jul 2017 11:13:18 -0700 Subject: [PATCH 03/17] remove trailing comma --- jupyterhub/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jupyterhub/app.py b/jupyterhub/app.py index d3b66bcc..299259a8 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -1162,7 +1162,7 @@ class JupyterHub(Application): self.oauth_provider = make_provider( self.session_factory, url_prefix=url_path_join(base_url, 'api/oauth2'), - login_url=url_path_join(base_url, 'login'), + login_url=url_path_join(base_url, 'login') ) def init_proxy(self): From c89711d0d5717ef72a39f8c04a5621cffd98410f Mon Sep 17 00:00:00 2001 From: Carol Willing Date: Thu, 20 Jul 2017 17:29:49 -0700 Subject: [PATCH 04/17] Edit and deduplicate security docs --- docs/source/security-basics.md | 146 -------------------------- docs/source/security-basics.rst | 176 ++++++++++++++++++++++++++++++++ docs/source/websecurity.md | 101 ++++++++++-------- 3 files changed, 233 insertions(+), 190 deletions(-) delete mode 100644 docs/source/security-basics.md create mode 100644 docs/source/security-basics.rst diff --git a/docs/source/security-basics.md b/docs/source/security-basics.md deleted file mode 100644 index 37876fde..00000000 --- a/docs/source/security-basics.md +++ /dev/null @@ -1,146 +0,0 @@ -# Security - -**IMPORTANT: You should not run JupyterHub without SSL encryption on a public network.** - ---- - -**Deprecation note:** Removed `--no-ssl` in version 0.7. - -JupyterHub versions 0.5 and 0.6 require extra confirmation via `--no-ssl` to -allow running without SSL using the command `jupyterhub --no-ssl`. The -`--no-ssl` command line option is not needed anymore in version 0.7. - ---- - -Security is the most important aspect of configuring Jupyter. There are four main aspects of the -security configuration: - -1. SSL encryption (to enable HTTPS) -2. Cookie secret (a key for encrypting browser cookies) -3. Proxy authentication token (used for the Hub and other services to authenticate to the Proxy) -4. Periodic security audits - -*Note* that the **Hub** hashes all secrets (e.g., auth tokens) before storing them in its -database. A loss of control over read-access to the database should have no security impact -on your deployment. - -## SSL encryption - -Since JupyterHub includes authentication and allows arbitrary code execution, you should not run -it without SSL (HTTPS). This will require you to obtain an official, trusted SSL certificate or -create a self-signed certificate. Once you have obtained and installed a key and certificate you -need to specify their locations in the configuration file as follows: - -```python -c.JupyterHub.ssl_key = '/path/to/my.key' -c.JupyterHub.ssl_cert = '/path/to/my.cert' -``` - -It is also possible to use letsencrypt (https://letsencrypt.org/) to obtain -a free, trusted SSL certificate. If you run letsencrypt using the default -options, the needed configuration is (replace `mydomain.tld` by your fully -qualified domain name): - -```python -c.JupyterHub.ssl_key = '/etc/letsencrypt/live/{mydomain.tld}/privkey.pem' -c.JupyterHub.ssl_cert = '/etc/letsencrypt/live/{mydomain.tld}/fullchain.pem' -``` - -If the fully qualified domain name (FQDN) is `example.com`, the following -would be the needed configuration: - -```python -c.JupyterHub.ssl_key = '/etc/letsencrypt/live/example.com/privkey.pem' -c.JupyterHub.ssl_cert = '/etc/letsencrypt/live/example.com/fullchain.pem' -``` - -Some cert files also contain the key, in which case only the cert is needed. It is important that -these files be put in a secure location on your server, where they are not readable by regular -users. - -Note on **chain certificates**: If you are using a chain certificate, see also -[chained certificate for SSL](troubleshooting.md#chained-certificates-for-ssl) in the JupyterHub troubleshooting FAQ). - -Note: In certain cases, e.g. **behind SSL termination in nginx**, allowing no SSL -running on the hub may be desired. - -## Cookie secret - -The cookie secret is an encryption key, used to encrypt the browser cookies used for -authentication. If this value changes for the Hub, all single-user servers must also be restarted. -Normally, this value is stored in a file, the location of which can be specified in a config file -as follows: - -```python -c.JupyterHub.cookie_secret_file = '/srv/jupyterhub/cookie_secret' -``` - -The content of this file should be 32 random bytes, encoded as hex. -An example would be to generate this file with: - -```bash -openssl rand -hex 32 > /srv/jupyterhub/cookie_secret -``` - -In most deployments of JupyterHub, you should point this to a secure location on the file -system, such as `/srv/jupyterhub/cookie_secret`. If the cookie secret file doesn't exist when -the Hub starts, a new cookie secret is generated and stored in the file. The -file must not be readable by group or other or the server won't start. -The recommended permissions for the cookie secret file are 600 (owner-only rw). - - -If you would like to avoid the need for files, the value can be loaded in the Hub process from -the `JPY_COOKIE_SECRET` environment variable, which is a hex-encoded string. You -can set it this way: - -```bash -export JPY_COOKIE_SECRET=`openssl rand -hex 32` -``` - -For security reasons, this environment variable should only be visible to the Hub. -If you set it dynamically as above, all users will be logged out each time the -Hub starts. - -You can also set the cookie secret in the configuration file itself,`jupyterhub_config.py`, -as a binary string: - -```python -c.JupyterHub.cookie_secret = bytes.fromhex('64 CHAR HEX STRING') -``` - -## Proxy authentication token - -The Hub authenticates its requests to the Proxy using a secret token that -the Hub and Proxy agree upon. The value of this string should be a random -string (for example, generated by `openssl rand -hex 32`). You can pass -this value to the Hub and Proxy using either the `CONFIGPROXY_AUTH_TOKEN` -environment variable: - -```bash -export CONFIGPROXY_AUTH_TOKEN=`openssl rand -hex 32` -``` - -This environment variable needs to be visible to the Hub and Proxy. - -Or you can set the value in the configuration file, `jupyterhub_config.py`: - -```python -c.JupyterHub.proxy_auth_token = '0bc02bede919e99a26de1e2a7a5aadfaf6228de836ec39a05a6c6942831d8fe5' -``` - -If you don't set the Proxy authentication token, the Hub will generate a random key itself, which -means that any time you restart the Hub you **must also restart the Proxy**. If the proxy is a -subprocess of the Hub, this should happen automatically (this is the default configuration). - -Another time you must set the Proxy authentication token yourself is if -you want other services, such as [nbgrader](https://github.com/jupyter/nbgrader) -to also be able to connect to the Proxy. - -## Security audits - -We recommend that you do periodic reviews of your deployment's security. It's -good practice to keep JupyterHub, configurable-http-proxy, and nodejs -versions up to date. - -A handy website for testing your deployment is -[Qualsys' SSL analyzer tool](https://www.ssllabs.com/ssltest/analyze.html). diff --git a/docs/source/security-basics.rst b/docs/source/security-basics.rst new file mode 100644 index 00000000..ceef9922 --- /dev/null +++ b/docs/source/security-basics.rst @@ -0,0 +1,176 @@ +Security +======== + +.. important:: + + You should not run JupyterHub without SSL encryption on a public network + +Security is the most important aspect of configuring Jupyter. There are four +main aspects of the security configuration: + +1. `SSL encryption `_ (to enable HTTPS) +2. `Cookie secret `_ (a key for encrypting browser cookies) +3. Proxy `authentication token `_ (used for the Hub and + other services to authenticate to the Proxy) +4. Periodic `security audits `_ + +The Hub hashes all secrets (e.g., auth tokens) before storing them in its +database. A loss of control over read-access to the database should have no +security impact on your deployment. + +.. _ssl-encryption: + +Enabling SSL encryption +----------------------- + +Since JupyterHub includes authentication and allows arbitrary code execution, +you should not run it without SSL (HTTPS). + +Using an SSL certificate +~~~~~~~~~~~~~~~~~~~~~~~~ + +This will require you to obtain an official, trusted SSL certificate or create a +self-signed certificate. Once you have obtained and installed a key and +certificate you need to specify their locations in the configuration file as +follows: + +.. code-block:: python + + c.JupyterHub.ssl_key = '/path/to/my.key' + c.JupyterHub.ssl_cert = '/path/to/my.cert' + + +Some cert files also contain the key, in which case only the cert is needed. It +is important that these files be put in a secure location on your server, where +they are not readable by regular users. + +If you are using a **chain certificate**, see also chained certificate for SSL +in the JupyterHub `troubleshooting FAQ `_. + +Using letsencrypt +~~~~~~~~~~~~~~~~~ + +It is also possible to use `letsencrypt `_ to obtain +a free, trusted SSL certificate. If you run letsencrypt using the default +options, the needed configuration is (replace ``mydomain.tld`` by your fully +qualified domain name): + +.. code-block:: python + + c.JupyterHub.ssl_key = '/etc/letsencrypt/live/{mydomain.tld}/privkey.pem' + c.JupyterHub.ssl_cert = '/etc/letsencrypt/live/{mydomain.tld}/fullchain.pem' + + +If the fully qualified domain name (FQDN) is ``example.com``, the following +would be the needed configuration: + +.. code-block:: python + + c.JupyterHub.ssl_key = '/etc/letsencrypt/live/example.com/privkey.pem' + c.JupyterHub.ssl_cert = '/etc/letsencrypt/live/example.com/fullchain.pem' + + +If SSL termination happens outside of the Hub +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In certain cases, e.g. behind `SSL termination in NGINX `_, +allowing no SSL running on the hub may be the desired configuration option. + +.. _cookie-secret: + +Cookie secret +------------- + +The cookie secret is an encryption key, used to encrypt the browser cookies used +for authentication. If this value changes for the Hub, all single-user servers +must also be restarted. + +Normally, this value is stored in a file, the location of which can be specified +in a config file as follows: + +.. code-block:: python + + c.JupyterHub.cookie_secret_file = '/srv/jupyterhub/cookie_secret' + + +The content of this file should be 32 random bytes, encoded as hex. +An example would be to generate this file with: + +.. code-block:: bash + + openssl rand -hex 32 > /srv/jupyterhub/cookie_secret + + +In most deployments of JupyterHub, you should point this to a secure location on +the file system, such as ``/srv/jupyterhub/cookie_secret``. If the cookie secret +file doesn't exist when the Hub starts, a new cookie secret is generated and +stored in the file. The file must not be readable by group or other or the +server won't start. The recommended permissions for the cookie secret file are +``600`` (owner-only rw). + + +If you would like to avoid the need for files, the value can be loaded in the +Hub process from the ``JPY_COOKIE_SECRET`` environment variable, which is a +hex-encoded string. You can set it this way: + +.. code-block:: bash + + export JPY_COOKIE_SECRET=`openssl rand -hex 32` + + +For security reasons, this environment variable should only be visible to the +Hub. If you set it dynamically as above, all users will be logged out each time +the Hub starts. + +You can also set the cookie secret in the configuration file +itself,``jupyterhub_config.py``, as a binary string: + +.. code-block:: python + + c.JupyterHub.cookie_secret = bytes.fromhex('64 CHAR HEX STRING') + + +.. _authentication-token: + +Proxy authentication token +-------------------------- + +The Hub authenticates its requests to the Proxy using a secret token that +the Hub and Proxy agree upon. The value of this string should be a random +string (for example, generated by ``openssl rand -hex 32``). You can pass +this value to the Hub and Proxy using either the ``CONFIGPROXY_AUTH_TOKEN`` +environment variable: + +.. code-block:: bash + + export CONFIGPROXY_AUTH_TOKEN=`openssl rand -hex 32` + + +This environment variable needs to be visible to the Hub and Proxy. + +Or you can set the value in the configuration file, ``jupyterhub_config.py``: + +.. code-block:: python + + c.JupyterHub.proxy_auth_token = '0bc02bede919e99a26de1e2a7a5aadfaf6228de836ec39a05a6c6942831d8fe5' + +If you don't set the Proxy authentication token, the Hub will generate a random +key itself, which means that any time you restart the Hub you **must also +restart the Proxy**. If the proxy is a subprocess of the Hub, this should happen +automatically (this is the default configuration). + +Another time you must set the Proxy authentication token yourself is if +you want other services, such as `nbgrader `_, +to also be able to connect to the Proxy. + +.. _security-audits: + +Security audits +--------------- + +We recommend that you do periodic reviews of your deployment's security. It's +good practice to keep JupyterHub, configurable-http-proxy, and nodejs +versions up to date. + +A handy website for testing your deployment is +[Qualsys' SSL analyzer tool](https://www.ssllabs.com/ssltest/analyze.html). diff --git a/docs/source/websecurity.md b/docs/source/websecurity.md index deeae646..e9484a1c 100644 --- a/docs/source/websecurity.md +++ b/docs/source/websecurity.md @@ -1,77 +1,90 @@ -# Web Security in JupyterHub +# Web Security and JupyterHub's design -JupyterHub is designed to be a simple multi-user server for modestly sized -groups of semi-trusted users. While the design reflects serving semi-trusted -users, JupyterHub is not necessarily unsuitable for serving untrusted users. -Using JupyterHub with untrusted users does mean more work and much care is -required to secure a Hub against untrusted users, with extra caution on +## JupyterHub's design approach + +JupyterHub is designed to be a *simple multi-user server for modestly sized +groups* of **semi-trusted** users. While the design reflects serving semi-trusted +users, JupyterHub is not necessarily unsuitable for serving **untrusted** users. + +Using JupyterHub with **untrusted** users does mean more work by the +administrator. Much care is required to secure a Hub, with extra caution on protecting users from each other as the Hub is serving untrusted users. -One aspect of JupyterHub's design simplicity for semi-trusted users is that -the Hub and single-user servers are placed in a single domain, behind a -[proxy][configurable-http-proxy]. As a result, if the Hub is serving untrusted +One aspect of JupyterHub's *design simplicity* for **semi-trusted** users is that +the Hub and single-user servers are placed in a *single domain*, behind a +[*proxy*][configurable-http-proxy]. If the Hub is serving untrusted users, many of the web's cross-site protections are not applied between single-user servers and the Hub, or between single-user servers and each other, since browsers see the whole thing (proxy, Hub, and single user -servers) as a single website. +servers) as a single website (i.e. single domain). -To protect users from each other, a user must never be able to write arbitrary +## How to protect users from each other + +To protect users from each other, a user must **never** be able to write arbitrary HTML and serve it to another user on the Hub's domain. JupyterHub's -authentication setup prevents this because only the owner of a given -single-user server is allowed to view user-authored pages served by their -server. To protect all users from each other, JupyterHub administrators must +authentication setup prevents a user writing arbitrary HTML and serving it to +another user because only the owner of a given single-user notebook server is +allowed to view user-authored pages served by the given single-user notebook +server. + +To protect all users from each other, JupyterHub administrators must ensure that: -* A user does not have permission to modify their single-user server: - - A user may not install new packages in the Python environment that runs - their server. - - If the PATH is used to resolve the single-user executable (instead of an - absolute path), a user may not create new files in any PATH directory - that precedes the directory containing jupyterhub-singleuser. +* A user **does not have permission** to modify their single-user notebook server, + including: + - A user **may not** install new packages in the Python environment that runs + their single-user server. + - If the `PATH` is used to resolve the single-user executable (instead of + using an absolute path), a user **may not** create new files in any `PATH` + directory that precedes the directory containing `jupyterhub-singleuser`. - A user may not modify environment variables (e.g. PATH, PYTHONPATH) for their single-user server. -* A user may not modify the configuration of the notebook server - (the ~/.jupyter or JUPYTER_CONFIG_DIR directory). +* A user **may not** modify the configuration of the notebook server + (the `~/.jupyter` or `JUPYTER_CONFIG_DIR` directory). If any additional services are run on the same domain as the Hub, the services -must never display user-authored HTML that is neither sanitized nor sandboxed +**must never** display user-authored HTML that is neither *sanitized* nor *sandboxed* (e.g. IFramed) to any user that lacks authentication as the author of a file. +## Mitigate security issues through configuration options -## Mitigations +There are two main approaches to mitigating these issues with configuration +options provided by JupyterHub: -There are two main configuration options provided by JupyterHub to mitigate -these issues: +### Enable subdomains -### Subdomains - -JupyterHub 0.5 adds the ability to run single-user servers on their own -subdomains, which means the cross-origin protections between servers has the +JupyterHub provides the ability to run single-user servers on their own +subdomains. This means the cross-origin protections between servers has the desired effect, and user servers and the Hub are protected from each other. A -user's server will be at `username.jupyter.mydomain.com`, etc. This requires -all user subdomains to point to the same address, which is most easily +user's single-user server will be at `username.jupyter.mydomain.com`. This also +requires all user subdomains to point to the same address, which is most easily accomplished with wildcard DNS. Since this spreads the service across multiple domains, you will need wildcard SSL, as well. Unfortunately, for many -institutional domains, wildcard DNS and SSL are not available, but if you do -plan to serve untrusted users, enabling subdomains is highly encouraged, as it -resolves all of the cross-site issues. +institutional domains, wildcard DNS and SSL are not available. **If you do plan +to serve untrusted users, enabling subdomains is highly encouraged**, as it +resolves the cross-site issues. -### Disabling user config +### Steps to take when subdomains can not be used -If subdomains are not available or not desirable, 0.5 also adds an option -`Spawner.disable_user_config`, which you can set to prevent the user-owned -configuration files from being loaded. This leaves only package installation -and PATHs as things the admin must enforce. +#### Disable user config -For most Spawners, PATH is not something users can influence, but care should -be taken to ensure that the Spawn does *not* evaluate shell configuration +If subdomains are not available or not desirable, JupyterHub provides a a +configuration option `Spawner.disable_user_config`, which can be set to prevent +the user-owned configuration files from being loaded. After implementing this +option, PATHs and package installation and PATHs are the other things that the +admin must enforce. + +#### Prevent spawners from evaluating shell configuration files + +For most Spawners, `PATH` is not something users can influence, but care should +be taken to ensure that the Spawner does *not* evaluate shell configuration files prior to launching the server. +#### Isolate packages using virtualenv + Package isolation is most easily handled by running the single-user server in a virtualenv with disabled system-site-packages. -## Extra notes - It is important to note that the control over the environment only affects the single-user server, and not the environment(s) in which the user's kernel(s) may run. Installing additional packages in the kernel environment does not From 6ee88a5424bbb34a11c477a996ee6db33752b44d Mon Sep 17 00:00:00 2001 From: Carol Willing Date: Thu, 20 Jul 2017 18:09:17 -0700 Subject: [PATCH 05/17] Edit content for clarity --- docs/source/security-basics.rst | 26 +++++++++++++------------- docs/source/websecurity.md | 20 ++++++++++++++------ 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/docs/source/security-basics.rst b/docs/source/security-basics.rst index ceef9922..5de09278 100644 --- a/docs/source/security-basics.rst +++ b/docs/source/security-basics.rst @@ -1,18 +1,19 @@ -Security -======== +Security basics +=============== .. important:: - You should not run JupyterHub without SSL encryption on a public network + You should not run JupyterHub without SSL encryption on a public network. -Security is the most important aspect of configuring Jupyter. There are four -main aspects of the security configuration: +Security is the most important aspect of configuring Jupyter. Three +configuration settings and one best practice are the main aspects of security +configuration: -1. `SSL encryption `_ (to enable HTTPS) -2. `Cookie secret `_ (a key for encrypting browser cookies) -3. Proxy `authentication token `_ (used for the Hub and +1. :ref:`SSL encryption ` (to enable HTTPS) +2. :ref:`Cookie secret ` (a key for encrypting browser cookies) +3. Proxy :ref:`authentication token ` (used for the Hub and other services to authenticate to the Proxy) -4. Periodic `security audits `_ +4. Periodic :ref:`security audits ` The Hub hashes all secrets (e.g., auth tokens) before storing them in its database. A loss of control over read-access to the database should have no @@ -100,11 +101,10 @@ An example would be to generate this file with: openssl rand -hex 32 > /srv/jupyterhub/cookie_secret - In most deployments of JupyterHub, you should point this to a secure location on the file system, such as ``/srv/jupyterhub/cookie_secret``. If the cookie secret file doesn't exist when the Hub starts, a new cookie secret is generated and -stored in the file. The file must not be readable by group or other or the +stored in the file. The file must not be readable by ``group`` or ``other`` or the server won't start. The recommended permissions for the cookie secret file are ``600`` (owner-only rw). @@ -123,7 +123,7 @@ Hub. If you set it dynamically as above, all users will be logged out each time the Hub starts. You can also set the cookie secret in the configuration file -itself,``jupyterhub_config.py``, as a binary string: +itself, ``jupyterhub_config.py``, as a binary string: .. code-block:: python @@ -173,4 +173,4 @@ good practice to keep JupyterHub, configurable-http-proxy, and nodejs versions up to date. A handy website for testing your deployment is -[Qualsys' SSL analyzer tool](https://www.ssllabs.com/ssltest/analyze.html). +`Qualsys' SSL analyzer tool `_. diff --git a/docs/source/websecurity.md b/docs/source/websecurity.md index e9484a1c..3ead7782 100644 --- a/docs/source/websecurity.md +++ b/docs/source/websecurity.md @@ -1,6 +1,11 @@ -# Web Security and JupyterHub's design +# Security Overview -## JupyterHub's design approach +The **Security Overview** section helps you learn about the design of JupyterHub +with respect to web security, the semi-trusted user, and the available +mitigations to protect untrusted users from each other. It also helps you +obtain a deeper understanding of how JupyterHub works. + +## Semi-trusted and untrusted users JupyterHub is designed to be a *simple multi-user server for modestly sized groups* of **semi-trusted** users. While the design reflects serving semi-trusted @@ -18,7 +23,7 @@ single-user servers and the Hub, or between single-user servers and each other, since browsers see the whole thing (proxy, Hub, and single user servers) as a single website (i.e. single domain). -## How to protect users from each other +## Protect users from each other To protect users from each other, a user must **never** be able to write arbitrary HTML and serve it to another user on the Hub's domain. JupyterHub's @@ -46,10 +51,10 @@ If any additional services are run on the same domain as the Hub, the services **must never** display user-authored HTML that is neither *sanitized* nor *sandboxed* (e.g. IFramed) to any user that lacks authentication as the author of a file. -## Mitigate security issues through configuration options +## Mitigate security issues There are two main approaches to mitigating these issues with configuration -options provided by JupyterHub: +options provided by JupyterHub. ### Enable subdomains @@ -64,7 +69,10 @@ institutional domains, wildcard DNS and SSL are not available. **If you do plan to serve untrusted users, enabling subdomains is highly encouraged**, as it resolves the cross-site issues. -### Steps to take when subdomains can not be used +### Unavailable subdomains + +When subdomains are not available or not desirable, three steps can be taken +to secure JupyterHub from untrusted users. #### Disable user config From fd7700d5777ac7b977b9449b082664ba474e5e1b Mon Sep 17 00:00:00 2001 From: Carol Willing Date: Thu, 20 Jul 2017 18:16:34 -0700 Subject: [PATCH 06/17] Update title --- docs/source/security-basics.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/security-basics.rst b/docs/source/security-basics.rst index 5de09278..a8165f47 100644 --- a/docs/source/security-basics.rst +++ b/docs/source/security-basics.rst @@ -1,5 +1,5 @@ -Security basics -=============== +Security configuration +====================== .. important:: From e8ebedb2da197433093a52f0c48a4e75976dfbbb Mon Sep 17 00:00:00 2001 From: Carol Willing Date: Thu, 20 Jul 2017 21:14:43 -0700 Subject: [PATCH 07/17] Move security audits to overview doc --- docs/source/security-basics.rst | 20 +++----------------- docs/source/websecurity.md | 23 +++++++++++++++++++---- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/docs/source/security-basics.rst b/docs/source/security-basics.rst index a8165f47..b4013969 100644 --- a/docs/source/security-basics.rst +++ b/docs/source/security-basics.rst @@ -1,19 +1,17 @@ -Security configuration -====================== +Security settings +================= .. important:: You should not run JupyterHub without SSL encryption on a public network. Security is the most important aspect of configuring Jupyter. Three -configuration settings and one best practice are the main aspects of security -configuration: +configuration settings are the main aspects of security configuration: 1. :ref:`SSL encryption ` (to enable HTTPS) 2. :ref:`Cookie secret ` (a key for encrypting browser cookies) 3. Proxy :ref:`authentication token ` (used for the Hub and other services to authenticate to the Proxy) -4. Periodic :ref:`security audits ` The Hub hashes all secrets (e.g., auth tokens) before storing them in its database. A loss of control over read-access to the database should have no @@ -162,15 +160,3 @@ automatically (this is the default configuration). Another time you must set the Proxy authentication token yourself is if you want other services, such as `nbgrader `_, to also be able to connect to the Proxy. - -.. _security-audits: - -Security audits ---------------- - -We recommend that you do periodic reviews of your deployment's security. It's -good practice to keep JupyterHub, configurable-http-proxy, and nodejs -versions up to date. - -A handy website for testing your deployment is -`Qualsys' SSL analyzer tool `_. diff --git a/docs/source/websecurity.md b/docs/source/websecurity.md index 3ead7782..bbf15193 100644 --- a/docs/source/websecurity.md +++ b/docs/source/websecurity.md @@ -1,9 +1,14 @@ # Security Overview -The **Security Overview** section helps you learn about the design of JupyterHub -with respect to web security, the semi-trusted user, and the available -mitigations to protect untrusted users from each other. It also helps you -obtain a deeper understanding of how JupyterHub works. +The **Security Overview** section helps you learn about: + +- the design of JupyterHub with respect to web security +- the semi-trusted user +- the available mitigations to protect untrusted users from each other +- the value of periodic security audits. + +This overview also helps you obtain a deeper understanding of how JupyterHub +works. ## Semi-trusted and untrusted users @@ -98,4 +103,14 @@ single-user server, and not the environment(s) in which the user's kernel(s) may run. Installing additional packages in the kernel environment does not pose additional risk to the web application's security. +## Security audits + +We recommend that you do periodic reviews of your deployment's security. It's +good practice to keep JupyterHub, configurable-http-proxy, and nodejs +versions up to date. + +A handy website for testing your deployment is +[Qualsys' SSL analyzer tool](https://www.ssllabs.com/ssltest/analyze.html). + + [configurable-http-proxy]: https://github.com/jupyterhub/configurable-http-proxy From 520d6160f0cd0aff71da50f55f7a9101e1150495 Mon Sep 17 00:00:00 2001 From: Carol Willing Date: Thu, 20 Jul 2017 21:52:46 -0700 Subject: [PATCH 08/17] Make use of config file and environment variable consistent --- docs/source/security-basics.rst | 84 +++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 29 deletions(-) diff --git a/docs/source/security-basics.rst b/docs/source/security-basics.rst index b4013969..886dd77b 100644 --- a/docs/source/security-basics.rst +++ b/docs/source/security-basics.rst @@ -80,32 +80,38 @@ allowing no SSL running on the hub may be the desired configuration option. Cookie secret ------------- -The cookie secret is an encryption key, used to encrypt the browser cookies used -for authentication. If this value changes for the Hub, all single-user servers -must also be restarted. +The cookie secret is an encryption key, used to encrypt the browser cookies +which are used for authentication. Three common methods are described for +generating and configuring the cookie secret. -Normally, this value is stored in a file, the location of which can be specified -in a config file as follows: +Generating and storing as a cookie secret file +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. code-block:: python - - c.JupyterHub.cookie_secret_file = '/srv/jupyterhub/cookie_secret' - - -The content of this file should be 32 random bytes, encoded as hex. -An example would be to generate this file with: +The cookie secret should be 32 random bytes, encoded as hex, and is typically +stored in a ``cookie_secret`` file. An example command to generate the +``cookie_secret`` file is: .. code-block:: bash openssl rand -hex 32 > /srv/jupyterhub/cookie_secret In most deployments of JupyterHub, you should point this to a secure location on -the file system, such as ``/srv/jupyterhub/cookie_secret``. If the cookie secret -file doesn't exist when the Hub starts, a new cookie secret is generated and -stored in the file. The file must not be readable by ``group`` or ``other`` or the -server won't start. The recommended permissions for the cookie secret file are -``600`` (owner-only rw). +the file system, such as ``/srv/jupyterhub/cookie_secret``. +The location of the ``cookie_secret_file`` can be specified in the +``jupyterhub_config.py`` file as follows: + +.. code-block:: python + + c.JupyterHub.cookie_secret_file = '/srv/jupyterhub/cookie_secret' + +If the cookie secret file doesn't exist when the Hub starts, a new cookie +secret is generated and stored in the file. The file must not be readable by +``group`` or ``other`` or the server won't start. The recommended permissions +for the cookie secret file are ``600`` (owner-only rw). + +Generating and storing as an environment variable +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If you would like to avoid the need for files, the value can be loaded in the Hub process from the ``JPY_COOKIE_SECRET`` environment variable, which is a @@ -115,11 +121,13 @@ hex-encoded string. You can set it this way: export JPY_COOKIE_SECRET=`openssl rand -hex 32` - For security reasons, this environment variable should only be visible to the Hub. If you set it dynamically as above, all users will be logged out each time the Hub starts. +Generating and storing as a binary string +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + You can also set the cookie secret in the configuration file itself, ``jupyterhub_config.py``, as a binary string: @@ -128,6 +136,12 @@ itself, ``jupyterhub_config.py``, as a binary string: c.JupyterHub.cookie_secret = bytes.fromhex('64 CHAR HEX STRING') +.. important:: + + If the cookie secret value changes for the Hub, all single-user notebook + servers must also be restarted. + + .. _authentication-token: Proxy authentication token @@ -135,16 +149,10 @@ Proxy authentication token The Hub authenticates its requests to the Proxy using a secret token that the Hub and Proxy agree upon. The value of this string should be a random -string (for example, generated by ``openssl rand -hex 32``). You can pass -this value to the Hub and Proxy using either the ``CONFIGPROXY_AUTH_TOKEN`` -environment variable: +string (for example, generated by ``openssl rand -hex 32``). -.. code-block:: bash - - export CONFIGPROXY_AUTH_TOKEN=`openssl rand -hex 32` - - -This environment variable needs to be visible to the Hub and Proxy. +Generating and storing token in the configuration file +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Or you can set the value in the configuration file, ``jupyterhub_config.py``: @@ -152,11 +160,29 @@ Or you can set the value in the configuration file, ``jupyterhub_config.py``: c.JupyterHub.proxy_auth_token = '0bc02bede919e99a26de1e2a7a5aadfaf6228de836ec39a05a6c6942831d8fe5' +Generating and storing as an environment variable +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can pass this value of the proxy authentication token to the Hub and Proxy +using the ``CONFIGPROXY_AUTH_TOKEN`` environment variable: + +.. code-block:: bash + + export CONFIGPROXY_AUTH_TOKEN='openssl rand -hex 32' + +This environment variable needs to be visible to the Hub and Proxy. + +Default if token is not set +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + If you don't set the Proxy authentication token, the Hub will generate a random key itself, which means that any time you restart the Hub you **must also restart the Proxy**. If the proxy is a subprocess of the Hub, this should happen automatically (this is the default configuration). -Another time you must set the Proxy authentication token yourself is if -you want other services, such as `nbgrader `_, +Setting a token when using services +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Another time you must set the Proxy authentication token yourself is if you +want other services, such as `nbgrader `_, to also be able to connect to the Proxy. From 51af6a98cc5a191d224cb635e3a6e15d70eb14f8 Mon Sep 17 00:00:00 2001 From: Carol Willing Date: Thu, 20 Jul 2017 21:59:03 -0700 Subject: [PATCH 09/17] Be clearer about the config file name --- docs/source/security-basics.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/source/security-basics.rst b/docs/source/security-basics.rst index 886dd77b..666a6cf6 100644 --- a/docs/source/security-basics.rst +++ b/docs/source/security-basics.rst @@ -30,8 +30,8 @@ Using an SSL certificate This will require you to obtain an official, trusted SSL certificate or create a self-signed certificate. Once you have obtained and installed a key and -certificate you need to specify their locations in the configuration file as -follows: +certificate you need to specify their locations in the ``jupyterhub_config.py`` +configuration file as follows: .. code-block:: python @@ -59,7 +59,6 @@ qualified domain name): c.JupyterHub.ssl_key = '/etc/letsencrypt/live/{mydomain.tld}/privkey.pem' c.JupyterHub.ssl_cert = '/etc/letsencrypt/live/{mydomain.tld}/fullchain.pem' - If the fully qualified domain name (FQDN) is ``example.com``, the following would be the needed configuration: From 9c21cf4c6221b518da61cc637dacc67f14b194ee Mon Sep 17 00:00:00 2001 From: Carol Willing Date: Fri, 21 Jul 2017 11:32:48 -0700 Subject: [PATCH 10/17] Add @minrk review comments --- docs/source/security-basics.rst | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/docs/source/security-basics.rst b/docs/source/security-basics.rst index 666a6cf6..968897ef 100644 --- a/docs/source/security-basics.rst +++ b/docs/source/security-basics.rst @@ -14,8 +14,9 @@ configuration settings are the main aspects of security configuration: other services to authenticate to the Proxy) The Hub hashes all secrets (e.g., auth tokens) before storing them in its -database. A loss of control over read-access to the database should have no -security impact on your deployment. +database. A loss of control over read-access to the database should have +minimal impact on your deployment; if your database has been compromised, it +is still a good idea to revoke existing tokens. .. _ssl-encryption: @@ -87,22 +88,22 @@ Generating and storing as a cookie secret file ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The cookie secret should be 32 random bytes, encoded as hex, and is typically -stored in a ``cookie_secret`` file. An example command to generate the -``cookie_secret`` file is: +stored in a ``jupyterhub_cookie_secret`` file. An example command to generate the +``jupyterhub_cookie_secret`` file is: .. code-block:: bash - openssl rand -hex 32 > /srv/jupyterhub/cookie_secret + openssl rand -hex 32 > /srv/jupyterhub/jupyterhub_cookie_secret In most deployments of JupyterHub, you should point this to a secure location on -the file system, such as ``/srv/jupyterhub/cookie_secret``. +the file system, such as ``/srv/jupyterhub/jupyterhub_cookie_secret``. -The location of the ``cookie_secret_file`` can be specified in the +The location of the ``jupyterhub_cookie_secret`` file can be specified in the ``jupyterhub_config.py`` file as follows: .. code-block:: python - c.JupyterHub.cookie_secret_file = '/srv/jupyterhub/cookie_secret' + c.JupyterHub.cookie_secret_file = '/srv/jupyterhub/jupyterhub_cookie_secret' If the cookie secret file doesn't exist when the Hub starts, a new cookie secret is generated and stored in the file. The file must not be readable by @@ -178,10 +179,3 @@ If you don't set the Proxy authentication token, the Hub will generate a random key itself, which means that any time you restart the Hub you **must also restart the Proxy**. If the proxy is a subprocess of the Hub, this should happen automatically (this is the default configuration). - -Setting a token when using services -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Another time you must set the Proxy authentication token yourself is if you -want other services, such as `nbgrader `_, -to also be able to connect to the Proxy. From be62b1b9df30fa6de74ad64bc19a82ce6b2927c1 Mon Sep 17 00:00:00 2001 From: Carol Willing Date: Fri, 21 Jul 2017 11:47:24 -0700 Subject: [PATCH 11/17] Reword based on @minrk's review --- docs/source/websecurity.md | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/docs/source/websecurity.md b/docs/source/websecurity.md index bbf15193..981fcecb 100644 --- a/docs/source/websecurity.md +++ b/docs/source/websecurity.md @@ -58,8 +58,8 @@ If any additional services are run on the same domain as the Hub, the services ## Mitigate security issues -There are two main approaches to mitigating these issues with configuration -options provided by JupyterHub. +Several approaches to mitigating these issues with configuration +options provided by JupyterHub include: ### Enable subdomains @@ -74,12 +74,7 @@ institutional domains, wildcard DNS and SSL are not available. **If you do plan to serve untrusted users, enabling subdomains is highly encouraged**, as it resolves the cross-site issues. -### Unavailable subdomains - -When subdomains are not available or not desirable, three steps can be taken -to secure JupyterHub from untrusted users. - -#### Disable user config +### Disable user config If subdomains are not available or not desirable, JupyterHub provides a a configuration option `Spawner.disable_user_config`, which can be set to prevent @@ -87,16 +82,17 @@ the user-owned configuration files from being loaded. After implementing this option, PATHs and package installation and PATHs are the other things that the admin must enforce. -#### Prevent spawners from evaluating shell configuration files +### Prevent spawners from evaluating shell configuration files For most Spawners, `PATH` is not something users can influence, but care should be taken to ensure that the Spawner does *not* evaluate shell configuration files prior to launching the server. -#### Isolate packages using virtualenv +### Isolate packages using virtualenv Package isolation is most easily handled by running the single-user server in -a virtualenv with disabled system-site-packages. +a virtualenv with disabled system-site-packages. The user should not have +permission to install packages into this environment. It is important to note that the control over the environment only affects the single-user server, and not the environment(s) in which the user's kernel(s) From e4541591ea985cffe7ec7401efea72fb30b8ebd0 Mon Sep 17 00:00:00 2001 From: Matthias Bussonnier Date: Fri, 21 Jul 2017 15:37:07 -0700 Subject: [PATCH 12/17] Do not 500 if cannot authenticate. self.authenticate can return None, in which case you can't subscript. So move extracting data into the branch checking whether authenticate is not `None`. Now that extracting the username is inside the if branch, it can't be used in the else one, so extract username from the request itself. This can be easily reproduce with the default PAM login with a wrong non existing/ wrong username. --- jupyterhub/handlers/login.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/jupyterhub/handlers/login.py b/jupyterhub/handlers/login.py index 4aeaad35..07b626ab 100644 --- a/jupyterhub/handlers/login.py +++ b/jupyterhub/handlers/login.py @@ -87,11 +87,11 @@ class LoginHandler(BaseHandler): authenticated = yield self.authenticate(data) auth_timer.stop(send=False) - # unpack auth dict - username = authenticated['name'] - auth_state = authenticated.get('auth_state') - if authenticated: + # unpack auth dict + username = authenticated['name'] + auth_state = authenticated.get('auth_state') + self.statsd.incr('login.success') self.statsd.timing('login.authenticate.success', auth_timer.ms) user = self.user_from_username(username) @@ -101,7 +101,7 @@ class LoginHandler(BaseHandler): already_running = False if user.spawner: status = yield user.spawner.poll() - already_running = (status == None) + already_running = (status is None) if not already_running and not user.spawner.options_form: yield self.spawn_single_user(user) self.set_login_cookie(user) @@ -117,7 +117,7 @@ class LoginHandler(BaseHandler): self.log.debug("Failed login for %s", data.get('username', 'unknown user')) html = self._render( login_error='Invalid username or password', - username=username, + username=data['username'], ) self.finish(html) From 1229fd100fa1f8a31f5d30d50ff8eec80fcd862d Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 24 Jul 2017 12:52:43 +0200 Subject: [PATCH 13/17] only set attributes on orm_server if they changed Setting things on orm_server set the dirty flag, even if they haven't changed. --- jupyterhub/objects.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/jupyterhub/objects.py b/jupyterhub/objects.py index c76e4ace..143ac6e5 100644 --- a/jupyterhub/objects.py +++ b/jupyterhub/objects.py @@ -83,7 +83,8 @@ class Server(HasTraits): @observe('ip', 'proto', 'port', 'base_url', 'cookie_name') def _change(self, change): if self.orm_server: - setattr(self.orm_server, change.name, change.new) + if getattr(self.orm_server, change.name) != change.new: + setattr(self.orm_server, change.name, change.new) @property def host(self): From ce53b11cf711458b0b0663883384882043f2a8e7 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 24 Jul 2017 12:53:58 +0200 Subject: [PATCH 14/17] Make rollback conditional on db.dirty avoids calling rollback when there are no changes includes warning about what objects are actually dirty --- jupyterhub/handlers/base.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index 7f202a65..3e7eb2fc 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -94,7 +94,9 @@ class BaseHandler(RequestHandler): def finish(self, *args, **kwargs): """Roll back any uncommitted transactions from the handler.""" - self.db.rollback() + if self.db.dirty: + self.log.warning("Rolling back dirty objects %s", self.db.dirty) + self.db.rollback() super().finish(*args, **kwargs) #--------------------------------------------------------------- From a0466dc322d7f9505182017c7efdf70095dbf556 Mon Sep 17 00:00:00 2001 From: yuvipanda Date: Mon, 24 Jul 2017 04:35:13 -0700 Subject: [PATCH 15/17] Count ourselves as a good route if we've a proxy pending --- jupyterhub/proxy.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/jupyterhub/proxy.py b/jupyterhub/proxy.py index 36636800..75d56521 100644 --- a/jupyterhub/proxy.py +++ b/jupyterhub/proxy.py @@ -304,6 +304,8 @@ class Proxy(LoggingConfigurable): self.log.warning( "Adding missing route for %s (%s)", user.name, user.server) futures.append(self.add_user(user)) + elif user.proxy_pending: + good_routes.add(user.proxy_spec) # check service routes service_routes = {r['data']['service'] From 69a6c79558f90ebb041e299883a97bda3b4776fa Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 24 Jul 2017 13:37:11 +0200 Subject: [PATCH 16/17] use admin user in test_admin rather than relying on multi db sessions talking to each other --- jupyterhub/tests/test_pages.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/jupyterhub/tests/test_pages.py b/jupyterhub/tests/test_pages.py index 237ac450..925bae40 100644 --- a/jupyterhub/tests/test_pages.py +++ b/jupyterhub/tests/test_pages.py @@ -72,10 +72,7 @@ def test_admin_not_admin(app): assert r.status_code == 403 def test_admin(app): - cookies = app.login_user('river') - u = orm.User.find(app.db, 'river') - u.admin = True - app.db.commit() + cookies = app.login_user('admin') r = get_page('admin', app, cookies=cookies) r.raise_for_status() assert r.url.endswith('/admin') From 4c86d100374b6e5343ccd02afeef8d0b61fed570 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 24 Jul 2017 14:12:50 +0200 Subject: [PATCH 17/17] comment about sqlachemy dirty flag --- jupyterhub/objects.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/jupyterhub/objects.py b/jupyterhub/objects.py index 143ac6e5..4b102d43 100644 --- a/jupyterhub/objects.py +++ b/jupyterhub/objects.py @@ -82,9 +82,12 @@ class Server(HasTraits): # setter to pass through to the database @observe('ip', 'proto', 'port', 'base_url', 'cookie_name') def _change(self, change): - if self.orm_server: - if getattr(self.orm_server, change.name) != change.new: - setattr(self.orm_server, change.name, change.new) + if self.orm_server and getattr(self.orm_server, change.name) != change.new: + # setattr on an sqlalchemy object sets the dirty flag, + # even if the value doesn't change. + # Avoid calling setattr when there's been no change, + # to avoid setting the dirty flag and triggering rollback. + setattr(self.orm_server, change.name, change.new) @property def host(self):