diff --git a/.pkgr.yml b/.pkgr.yml index 014e23e2bb1..7f7947a1976 100644 --- a/.pkgr.yml +++ b/.pkgr.yml @@ -27,6 +27,7 @@ targets: - NODE_ENV=production - NPM_CONFIG_PRODUCTION=false - SECRET_KEY_BASE=1 + - OPENPROJECT_DISABLE__SECRET_KEY_BASE__CHECK=true dependencies: - epel-release - ImageMagick diff --git a/config/initializers/02-secret_key_base_check.rb b/config/initializers/02-secret_key_base_check.rb new file mode 100644 index 00000000000..8849ba62bc4 --- /dev/null +++ b/config/initializers/02-secret_key_base_check.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +# Refuse to boot in production if SECRET_KEY_BASE is a known-weak default that +# we have shipped in our Dockerfile or referenced in our documentation. +# An attacker who knows the secret can forge signed cookies and session data +# and decrypt anything derived from it. +insecure_secret_key_bases = [ + "OVERWRITE_ME", # default value in Dockerfile + "secret", # old sample from documentation + "" # new sample from documentation +].freeze + +no_rake_task = !(Rake.respond_to?(:application) && Rake.application.top_level_tasks.present?) +no_override = ENV["OPENPROJECT_DISABLE__SECRET_KEY_BASE__CHECK"] != "true" +if Rails.env.production? && no_rake_task && no_override + secret = ENV["SECRET_KEY_BASE"].to_s + fatal_reason = + if secret.empty? + "SECRET_KEY_BASE is not set." + elsif insecure_secret_key_bases.include?(secret) + "SECRET_KEY_BASE is set to a well-known default value (#{secret.inspect})." + end + + if fatal_reason + abort <<~ERROR # rubocop:disable Rails/Exit + ======= INSECURE SECRET_KEY_BASE DETECTED ======= + #{fatal_reason} + + OpenProject uses SECRET_KEY_BASE to sign cookies, sessions, and other + security-sensitive data. Running with a default or weak value allows + anyone to forge signed data and compromise the installation. + + Generate a strong, random value (for example via `openssl rand -hex 64`) + and provide it via the SECRET_KEY_BASE environment variable. The same + value MUST be reused on every container/process start, otherwise + existing sessions and encrypted database content become unreadable. + + If you know what you are doing and want to disable this check, set the environment + variable OPENPROJECT_DISABLE__SECRET_KEY_BASE__CHECK to "true" (not recommended). + + Documentation: + - https://www.openproject.org/docs/installation-and-operations/installation/docker/ + + ================================================= + ERROR + end +end diff --git a/config/initializers/cookies_serializer.rb b/config/initializers/cookies_serializer.rb index b728aeaf9c2..4346142d319 100644 --- a/config/initializers/cookies_serializer.rb +++ b/config/initializers/cookies_serializer.rb @@ -32,4 +32,4 @@ # Specify a serializer for the signed and encrypted cookie jars. # Valid options are :json, :marshal, and :hybrid. -Rails.application.config.action_dispatch.cookies_serializer = :marshal +Rails.application.config.action_dispatch.cookies_serializer = :json diff --git a/docker/prod/setup/precompile-assets.sh b/docker/prod/setup/precompile-assets.sh index 1ab0388486f..b75b9b4de45 100755 --- a/docker/prod/setup/precompile-assets.sh +++ b/docker/prod/setup/precompile-assets.sh @@ -9,7 +9,7 @@ else echo "Assets need to be compiled" JOBS=8 npm install - SECRET_KEY_BASE=1 RAILS_ENV=production DATABASE_URL=nulldb://db \ + SECRET_KEY_BASE="$(openssl rand -hex 64)" RAILS_ENV=production DATABASE_URL=nulldb://db \ bin/rails openproject:plugins:register_frontend assets:precompile if [ "$DOCKER" = "1" ]; then diff --git a/docs/development/profiling/README.md b/docs/development/profiling/README.md index 9ee193abb75..7fe85d5743a 100644 --- a/docs/development/profiling/README.md +++ b/docs/development/profiling/README.md @@ -49,7 +49,7 @@ gem 'stackprof' Start thin via: ```shell -SECRET_KEY_BASE='abcd' RAILS_ENV=production CUSTOM_PLUGIN_GEMFILE=gemfile.profiling OPENPROJECT_RACK_PROFILER_ENABLED=true thin start +SECRET_KEY_BASE="$(openssl rand -hex 64)" RAILS_ENV=production CUSTOM_PLUGIN_GEMFILE=gemfile.profiling OPENPROJECT_RACK_PROFILER_ENABLED=true thin start ``` ## Using the profiling tools diff --git a/docs/installation-and-operations/installation/docker/README.md b/docs/installation-and-operations/installation/docker/README.md index 15ae8029b2d..d447eff6385 100644 --- a/docs/installation-and-operations/installation/docker/README.md +++ b/docs/installation-and-operations/installation/docker/README.md @@ -71,7 +71,7 @@ following command: ```shell docker run -it -p 8080:80 \ - -e SECRET_KEY_BASE=secret \ + -e SECRET_KEY_BASE= \ -e OPENPROJECT_HOST__NAME=localhost:8080 \ -e OPENPROJECT_HTTPS=false \ -e OPENPROJECT_DEFAULT__LANGUAGE=en \ @@ -81,7 +81,7 @@ docker run -it -p 8080:80 \ Explanation of the used configuration values: - `-p 8080:80` binds the port 80 of the container to 8080 on the machine running docker. -- `SECRET_KEY_BASE` sets the secret key base for Rails. Please use a pseudo-random value for this and treat it like a password. +- `SECRET_KEY_BASE` sets the secret key base for Rails. Replace `` with a strong, random value (for example generated with `openssl rand -hex 64`). Treat it like a password and **store it securely** — the same value must be reused on every container start, otherwise existing sessions and encrypted database content become unreadable. OpenProject will refuse to start with a default or weak value. - `OPENPROJECT_HOST__NAME` sets the host name of the application. This value is used for generating forms and links in emails, and needs to match the external request host name (The value users are seeing in their browsers). - `OPENPROJECT_HTTPS=false` disables the on-by-default HTTPS mode of OpenProject so you can access the instance over HTTP-only. For all production systems we strongly advise not to set this to false, and instead set up a proper TLS/SSL termination on your outer web server. - `OPENPROJECT_DEFAULT__LANGUAGE` does two things. It controls for the very first installation, in which language basic data (such as types, status names, etc.) and demo data is being created in. It also sets the default fallback language for new users. @@ -102,7 +102,7 @@ achieved with the `-d` flag: ```shell docker run -d -p 8080:80 \ - -e SECRET_KEY_BASE=secret \ + -e SECRET_KEY_BASE= \ -e OPENPROJECT_HOST__NAME=localhost:8080 \ -e OPENPROJECT_HTTPS=false \ openproject/openproject:17 @@ -134,7 +134,7 @@ sudo mkdir -p /var/lib/openproject/{pgdata,assets} docker run -d -p 8080:80 --name openproject \ -e OPENPROJECT_HOST__NAME=openproject.example.com \ - -e SECRET_KEY_BASE=secret \ + -e SECRET_KEY_BASE= \ -v /var/lib/openproject/pgdata:/var/openproject/pgdata \ -v /var/lib/openproject/assets:/var/openproject/assets \ openproject/openproject:17 @@ -143,7 +143,7 @@ docker run -d -p 8080:80 --name openproject \ Please make sure you set the correct public facing hostname in `OPENPROJECT_HOST__NAME`. If you don't have a load-balancing or proxying web server in front of your docker container, you will otherwise be vulnerable to [HOST header injections](https://portswigger.net/web-security/host-header), as the internal server has no way of identifying the correct host name. We strongly recommend you use an external load-balancing or proxying web server for termination of TLS/SSL and general security hardening. -**Note**: Make sure to replace `secret` with a random string. One way to generate one is to run `head /dev/urandom | tr -dc A-Za-z0-9 | head -c 32 ; echo ''` if you are on Linux. +**Note**: Make sure to replace `` with a random string. One way to generate one is to run `openssl rand -hex 64`. Store this value securely — it must remain the same across container restarts, otherwise sessions and encrypted database content will be lost. **Note**: MacOS users might encounter an "Operation not permitted" error on the mounted directories. The fix for this is to create the two directories in a user-owned directory of the host machine. @@ -474,7 +474,7 @@ The first way is to mount the root certificate via the ```--mount``` option into ```shell sudo docker run -it -p 8080:80 \ - -e SECRET_KEY_BASE=secret \ + -e SECRET_KEY_BASE= \ -e OPENPROJECT_HOST__NAME=localhost:8080 \ -e OPENPROJECT_HTTPS=false \ -e OPENPROJECT_DEFAULT__LANGUAGE=en \ diff --git a/docs/installation-and-operations/misc/migration-to-postgresql13/README.md b/docs/installation-and-operations/misc/migration-to-postgresql13/README.md index 533361db10e..014c7c21d05 100644 --- a/docs/installation-and-operations/misc/migration-to-postgresql13/README.md +++ b/docs/installation-and-operations/misc/migration-to-postgresql13/README.md @@ -213,7 +213,7 @@ sudo mv /var/lib/openproject/pgdata-next /var/lib/openproject/pgdata Finally, you can restart OpenProject with the same command that you used before. For instance: -docker run -d -p 8080:80 --name openproject -e SECRET_KEY_BASE=secret \ +docker run -d -p 8080:80 --name openproject -e SECRET_KEY_BASE= \ -v /var/lib/openproject/pgdata:/var/openproject/pgdata \ -v /var/lib/openproject/assets:/var/openproject/assets \ [...] diff --git a/docs/installation-and-operations/misc/migration-to-postgresql17/README.md b/docs/installation-and-operations/misc/migration-to-postgresql17/README.md index b839d9f1829..dc32c2b5f4d 100644 --- a/docs/installation-and-operations/misc/migration-to-postgresql17/README.md +++ b/docs/installation-and-operations/misc/migration-to-postgresql17/README.md @@ -165,7 +165,7 @@ You can now run a new OpenProject container connected to your upgraded PostgreSQ ```bash docker run -d -p 8080:80 --name openproject \ -e OPENPROJECT_HOST__NAME=openproject.example.com \ - -e SECRET_KEY_BASE=secret \ + -e SECRET_KEY_BASE= \ -v /var/lib/openproject/pgdata17:/var/openproject/pgdata \ -v /var/lib/openproject/assets:/var/openproject/assets \ openproject/openproject:17 diff --git a/docs/installation-and-operations/operation/backing-up/README.md b/docs/installation-and-operations/operation/backing-up/README.md index 9351f53c7e3..4e91cbce0fb 100644 --- a/docs/installation-and-operations/operation/backing-up/README.md +++ b/docs/installation-and-operations/operation/backing-up/README.md @@ -59,7 +59,7 @@ If you are using the all-in-one container, then you can simply backup any local ```shell sudo mkdir -p /var/lib/openproject/{pgdata,assets} -docker run -d -p 8080:80 --name openproject -e SECRET_KEY_BASE=secret \ +docker run -d -p 8080:80 --name openproject -e SECRET_KEY_BASE= \ -v /var/lib/openproject/pgdata:/var/openproject/pgdata \ -v /var/lib/openproject/assets:/var/openproject/assets \ openproject/openproject:17 diff --git a/spec/security/cookies_serializer_spec.rb b/spec/security/cookies_serializer_spec.rb new file mode 100644 index 00000000000..3d8fbf1a8a4 --- /dev/null +++ b/spec/security/cookies_serializer_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +# Regression guard against the Marshal.load-via-encrypted-cookie RCE chain. +# If anyone flips `cookies_serializer` back to :marshal or :hybrid, the +# 2FA op2fa_remember_token cookie (and any future encrypted cookie) becomes +# a pre-auth RCE primitive as soon as the signing key is known or leaks. + +require "spec_helper" + +# Canary class used to detect Marshal.load running against attacker-controlled +# cookie payloads. Must be a named top-level constant so Marshal can dump and +# reconstruct it by name. We record the invocation in a class-level flag because +# the cookie jar swallows exceptions raised during deserialization — an +# `expect { … }.not_to raise_error` assertion would not catch a regression. +class CookieSerializerSpecMarshalCanary + @triggered = false + class << self + attr_accessor :triggered + end + + def marshal_dump = "canary" + + def marshal_load(_) + CookieSerializerSpecMarshalCanary.triggered = true + end +end + +RSpec.describe "Cookie serializer" do # rubocop:disable RSpec/DescribeClass + it "is configured to :json (NEVER :marshal or :hybrid)" do + expect(Rails.application.config.action_dispatch.cookies_serializer).to eq(:json) + end + + describe "encrypted cookie jar" do + # Forge a cookie whose ciphertext, after authenticated decryption, yields a + # Marshal payload. This mimics either (a) a cookie written by an older + # version of OpenProject when the serializer was :marshal, or (b) an attacker + # who knows SECRET_KEY_BASE (e.g. the historical OVERWRITE_ME for unconfigured + # docker containers) and crafts an exploit payload. + let(:marshal_payload) { Marshal.dump(CookieSerializerSpecMarshalCanary.new) } + let(:forged_cookie) do + salt = Rails.application.config.action_dispatch.authenticated_encrypted_cookie_salt + cipher = Rails.application.config.action_dispatch.encrypted_cookie_cipher || "aes-256-gcm" + key_len = ActiveSupport::MessageEncryptor.key_len(cipher) + secret = Rails.application.key_generator.generate_key(salt, key_len) + + encryptor = ActiveSupport::MessageEncryptor.new( + secret, + cipher: cipher, + serializer: ActiveSupport::MessageEncryptor::NullSerializer + ) + encryptor.encrypt_and_sign(marshal_payload, purpose: "cookie.op2fa_remember_token") + end + + let(:cookie_jar) do + request = ActionDispatch::TestRequest.create + request.cookies["op2fa_remember_token"] = forged_cookie + ActionDispatch::Cookies::CookieJar.build(request, request.cookies) + end + + before { CookieSerializerSpecMarshalCanary.triggered = false } + + it "does not invoke Marshal.load on the cookie payload" do + # If the serializer were :marshal/:hybrid, the encrypted cookie jar would + # call Marshal.load on the decrypted payload, reconstructing the canary + # and setting its `triggered` flag. With :json, the JSON deserializer + # rejects the Marshal bytes and the jar returns nil — no Ruby objects + # are revived from attacker-controlled data. + cookie_jar.encrypted["op2fa_remember_token"] + + expect(CookieSerializerSpecMarshalCanary.triggered).to be(false), + "Marshal.load was invoked on attacker-controlled cookie payload — " \ + "cookies_serializer must be :json, not :marshal or :hybrid." + end + end +end