diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 10919e0228b..7617b4dbefd 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -137,30 +137,25 @@ jobs:
- platform: linux/amd64
digest: amd64-slim
bim_support: false
- debian_base: trixie
target: slim
runner: runner=4cpu-linux-x64
- platform: linux/amd64
digest: amd64-bim
bim_support: true
- debian_base: bookworm
target: slim-bim
runner: runner=4cpu-linux-x64
- platform: linux/arm64/v8
digest: arm64-slim
- debian_base: trixie
bim_support: false
target: slim
runner: runner=4cpu-linux-arm64
- platform: linux/amd64
digest: amd64-aio
- debian_base: bookworm
bim_support: true
target: all-in-one
runner: runner=4cpu-linux-x64
- platform: linux/arm64/v8
digest: arm64-aio
- debian_base: trixie
bim_support: false
target: all-in-one
runner: runner=4cpu-linux-arm64
diff --git a/app/controllers/repositories_controller.rb b/app/controllers/repositories_controller.rb
index dc003420aa9..d05ade3edd2 100644
--- a/app/controllers/repositories_controller.rb
+++ b/app/controllers/repositories_controller.rb
@@ -446,11 +446,11 @@ class RepositoriesController < ApplicationController
end
def send_raw(content, path)
- # Force the download
- send_opt = { filename: filename_for_content_disposition(path.split("/").last) }
- send_type = OpenProject::MimeType.of(path)
- send_opt[:type] = send_type.to_s if send_type
- send_data content, send_opt
+ # Force the download as binary to prevent CSP bypass
+ send_data content,
+ filename: filename_for_content_disposition(path.split("/").last),
+ type: "application/octet-stream",
+ disposition: :attachment
end
def render_text_entry
diff --git a/app/helpers/repositories_helper.rb b/app/helpers/repositories_helper.rb
index 7b3d9802693..5e8343e159d 100644
--- a/app/helpers/repositories_helper.rb
+++ b/app/helpers/repositories_helper.rb
@@ -114,13 +114,13 @@ module RepositoriesHelper
end
def render_changes_tree(tree)
- return "" if tree.nil?
+ return "".html_safe if tree.nil?
- output = +"
"
- tree.keys.sort.each do |file|
- style = +"change"
+ items = tree.keys.sort.flat_map do |file|
+ style = "change"
text = File.basename(file)
- if s = tree[file][:s]
+
+ if (s = tree[file][:s])
style += " folder"
path_param = without_leading_slash(to_path_param(@repository.relative_path(file)))
text = link_to(h(text),
@@ -129,38 +129,40 @@ module RepositoriesHelper
rev: @changeset.identifier),
title: I18n.t(:label_folder))
- output += "- #{text}
"
- output += render_changes_tree(s)
- elsif c = tree[file][:c]
+ folder_li = content_tag(:li, text,
+ class: "#{style} icon icon-folder-#{calculate_folder_action(s)}")
+ [folder_li, render_changes_tree(s)]
+ elsif (c = tree[file][:c])
style += " change-#{c.action}"
path_param = without_leading_slash(to_path_param(@repository.relative_path(c.path)))
- unless c.action == "D"
- title_text = changes_tree_change_title c.action
+ text_parts = []
+ unless c.action == "D"
text = link_to(h(text),
entry_revision_project_repository_path(project_id: @project,
repo_path: path_param,
rev: @changeset.identifier),
- title: title_text)
+ title: changes_tree_change_title(c.action))
end
- text << raw(" - #{h(c.revision)}") if c.revision.present?
+ text_parts << text
+ text_parts << " - " << h(c.revision) if c.revision.present?
if c.action == "M"
- text << raw(" (" + link_to(I18n.t(:label_diff),
- diff_revision_project_repository_path(project_id: @project,
- repo_path: path_param,
- rev: @changeset.identifier)) + ") ")
+ text_parts << " (" << link_to(I18n.t(:label_diff),
+ diff_revision_project_repository_path(project_id: @project,
+ repo_path: path_param,
+ rev: @changeset.identifier)) << ") "
end
- text << raw(" " + content_tag("span", h(c.from_path), class: "copied-from")) if c.from_path.present?
+ text_parts << " " << content_tag(:span, c.from_path, class: "copied-from") if c.from_path.present?
- output += changes_tree_li_element(c.action, text, style)
+ [changes_tree_li_element(c.action, safe_join(text_parts), style)]
end
- end
- output += "
"
- output.html_safe
+ end.compact
+
+ content_tag(:ul, safe_join(items))
end
def to_utf8_for_repositories(str)
@@ -296,19 +298,16 @@ module RepositoriesHelper
def changes_tree_li_element(action, text, style)
icon_name = case action
- when "A"
- "icon-add"
- when "D"
- "icon-delete"
- when "C"
- "icon-copy"
- when "R"
- "icon-rename"
+ when "A" then "icon-add"
+ when "D" then "icon-delete"
+ when "C" then "icon-copy"
+ when "R" then "icon-rename"
else
"icon-arrow-left-right"
end
- "#{text}"
+ content_tag(:li, text,
+ class: "#{style} icon #{icon_name}",
+ title: changes_tree_change_title(action))
end
end
diff --git a/app/models/custom_field.rb b/app/models/custom_field.rb
index b676788ac5f..0e06e9a0277 100644
--- a/app/models/custom_field.rb
+++ b/app/models/custom_field.rb
@@ -32,6 +32,8 @@ class CustomField < ApplicationRecord
include CustomField::OrderStatements
include CustomField::CalculatedValue
+ normalizes :name, with: OpenProject::RemoveAsciiControlCharacters
+
has_many :custom_values, dependent: :delete_all
# WARNING: the inverse_of option is also required in order
# for the 'touch: true' option on the custom_field association in CustomOption
diff --git a/config/initializers/00-load_plugins.rb b/config/initializers/00-load_plugins.rb
index 2ea001b64f4..e2de30f5ab6 100644
--- a/config/initializers/00-load_plugins.rb
+++ b/config/initializers/00-load_plugins.rb
@@ -31,7 +31,16 @@
# TODO: check if this can be postponed and if some plugins can make use of the ActiveSupport.on_load hooks
# Loads the core plugins located in lib_static/plugins
-Dir.glob(Rails.root.join("lib_static/plugins/*")).each do |directory|
+CORE_PLUGINS = %w[
+ acts_as_attachable
+ acts_as_customizable
+ acts_as_event
+ acts_as_journalized
+ acts_as_searchable
+ verification
+].freeze
+
+CORE_PLUGINS.map { |name| Rails.root.join("lib_static/plugins", name).to_s }.each do |directory|
if File.directory?(directory)
lib = File.join(directory, "lib")
diff --git a/db/migrate/20260313120000_strip_control_characters_from_custom_field_names.rb b/db/migrate/20260313120000_strip_control_characters_from_custom_field_names.rb
new file mode 100644
index 00000000000..aec50ce1ec6
--- /dev/null
+++ b/db/migrate/20260313120000_strip_control_characters_from_custom_field_names.rb
@@ -0,0 +1,43 @@
+# 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.
+#++
+
+class StripControlCharactersFromCustomFieldNames < ActiveRecord::Migration[7.1]
+ def up
+ execute <<~SQL.squish
+ UPDATE custom_fields
+ SET name = regexp_replace(name, E'[\\x01-\\x1F\\x7F]', '', 'g')
+ WHERE name ~ E'[\\x01-\\x1F\\x7F]'
+ SQL
+ end
+
+ def down
+ # Irreversible — stripped characters cannot be restored
+ end
+end
diff --git a/docker/prod/Dockerfile b/docker/prod/Dockerfile
index 0700b31bd99..4ab8cfd4fcd 100755
--- a/docker/prod/Dockerfile
+++ b/docker/prod/Dockerfile
@@ -141,7 +141,7 @@ ENV PGDATA=/var/openproject/pgdata
COPY --from=openproject/gosu /go/bin/gosu /usr/local/bin/gosu
RUN chmod +x /usr/local/bin/gosu && gosu nobody true
-COPY --from=openproject/hocuspocus:17.2.0 --chown=$APP_USER:$APP_USER /app /opt/hocuspocus
+COPY --from=openproject/hocuspocus:17.2.1 --chown=$APP_USER:$APP_USER /app /opt/hocuspocus
# Keep node/npm in all-in-one for bundled hocuspocus even when BIM support is disabled.
COPY --from=build-base /usr/local/bin/node /usr/local/bin/node
COPY --from=build-base /usr/local/lib/node_modules /usr/local/lib/node_modules
diff --git a/docs/release-notes/16/16-6-9/README.md b/docs/release-notes/16/16-6-9/README.md
new file mode 100644
index 00000000000..612f924f57d
--- /dev/null
+++ b/docs/release-notes/16/16-6-9/README.md
@@ -0,0 +1,89 @@
+---
+title: OpenProject 16.6.9
+sidebar_navigation:
+ title: 16.6.9
+release_version: 16.6.9
+release_date: 2026-03-16
+---
+
+ # OpenProject 16.6.9
+
+ Release date: 2026-03-16
+
+ We released OpenProject [OpenProject 16.6.9](https://community.openproject.org/versions/2285).
+ The release contains several bug fixes and we recommend updating to the newest version.
+ Below you will find a complete list of all changes and bug fixes.
+
+
+
+## Security fixes
+
+
+
+### CVE-2026-32698 - SQL Injection via Custom Field Name can be chained to Remote Code Execution
+
+OpenProject is vulnerable to an SQL injection attack via a custom field's name. When that custom field was used in a Cost Report, the custom field's name was injected into the SQL query without proper sanitation. This allowed an attacker to execute arbitrary SQL commands during the generation of a Cost Report.
+
+
+
+As custom fields can only be generated by users with full administrator privileges, the attack surface is somewhat reduced.
+
+
+
+Together with another bug in the _Repositories_ module, that used the project identifier without sanitation to generate the checkout path for a git repository in the filesystem, this allowed an attacker to checkout a git repository to an arbitrarily chosen path on the server. If the checkout is done within certain paths within the OpenProject application, upon the next restart of the application, this allows the attacker to inject ruby code into the application.
+
+
+
+As the project identifier cannot be manually edited to any string containing special characters like dots or slashes, this needs to be changed via the SQL injection described above.
+
+
+
+This vulnerability was reported by user [sam91281](https://yeswehack.com/hunters/sam91281) as part of the [YesWeHack.com OpenProject Bug Bounty program](https://yeswehack.com/programs/openproject), sponsored by the European Commission.
+
+
+
+For more information, please see the [GitHub advisory #GHSA-jqhf-rf9x-9rhx](https://github.com/opf/openproject/security/advisories/GHSA-jqhf-rf9x-9rhx)
+
+
+
+### CVE-2026-32703 - Repository files are served with the MIME type allowing them to be used to bypass Content Security Policy
+
+When using the Repositories module in a project, it was possible to access the raw files via the browser with a URL like `/projects/{project}/repository/revisions/{commit_id}/raw/{file}.js.raw`. For those files, the MIME type was detected via the filename extension. For JavaScript and CSS files those files were then served from the same domain name as the application with the correct MIME type for active content and could be used to bypass the Content Security Policy. Together with other areas, where unsanitized HTML was served, this allowed persistent XSS attacks.
+
+
+
+The MIME type detection for Repository files has been removed and files are served as `application/octet-stream` which will block their execution via the Content Security Policy.
+
+
+
+Two places that could be used to abuse this vulnerability have been fixed:
+
+
+
+The Repositories module did not properly escape filenames displayed from repositories. This allowed an attacker with push access into the repository to create commits with filenames that included HTML code that was injected in the page without proper sanitation. This allowed a persisted XSS attack against all members of this project that accessed the repositories page to display a changeset where the maliciously crafted file was deleted.
+
+
+
+When a work package name contains HTML content and the work package is attached to a meeting, the work package name is rendered in the activities feed without proper sanitation.
+
+
+
+All of those vulnerabilities were reported by user [sam91281](https://yeswehack.com/hunters/sam91281) as part of the [YesWeHack.com OpenProject Bug Bounty program](https://yeswehack.com/programs/openproject), sponsored by the European Commission.
+
+
+
+For more information, please see the [GitHub advisory #GHSA-p423-72h4-fjvp](https://github.com/opf/openproject/security/advisories/GHSA-p423-72h4-fjvp)
+
+
+
+
+
+
+## Bug fixes and changes
+
+
+
+
+
+
+
diff --git a/docs/release-notes/16/README.md b/docs/release-notes/16/README.md
index fb3cc37cb27..6eddb7fd3a3 100644
--- a/docs/release-notes/16/README.md
+++ b/docs/release-notes/16/README.md
@@ -1,7 +1,7 @@
---
sidebar_navigation:
title: '16.x'
-release_date: 2026-02-18
+release_date: 2026-03-16
title: OpenProject Version 16 Release Notes
---
@@ -9,6 +9,7 @@ title: OpenProject Version 16 Release Notes
| Version | Release date |
|-------------------------------|--------------|
+| [OpenProject 16.6.8](16-6-9/) | 2026-03-16 |
| [OpenProject 16.6.8](16-6-8/) | 2026-02-18 |
| [OpenProject 16.6.7](16-6-7/) | 2026-02-06 |
| [OpenProject 16.6.6](16-6-6/) | 2026-01-27 |
diff --git a/docs/release-notes/17-0-6/README.md b/docs/release-notes/17-0-6/README.md
new file mode 100644
index 00000000000..18ae4060379
--- /dev/null
+++ b/docs/release-notes/17-0-6/README.md
@@ -0,0 +1,89 @@
+---
+title: OpenProject 17.0.6
+sidebar_navigation:
+ title: 17.0.6
+release_version: 17.0.6
+release_date: 2026-03-16
+---
+
+ # OpenProject 17.0.6
+
+ Release date: 2026-03-16
+
+ We released OpenProject [OpenProject 17.0.6](https://community.openproject.org/versions/2286).
+ The release contains several bug fixes and we recommend updating to the newest version.
+ Below you will find a complete list of all changes and bug fixes.
+
+
+
+## Security fixes
+
+
+
+### CVE-2026-32698 - SQL Injection via Custom Field Name can be chained to Remote Code Execution
+
+OpenProject is vulnerable to an SQL injection attack via a custom field's name. When that custom field was used in a Cost Report, the custom field's name was injected into the SQL query without proper sanitation. This allowed an attacker to execute arbitrary SQL commands during the generation of a Cost Report.
+
+
+
+As custom fields can only be generated by users with full administrator privileges, the attack surface is somewhat reduced.
+
+
+
+Together with another bug in the _Repositories_ module, that used the project identifier without sanitation to generate the checkout path for a git repository in the filesystem, this allowed an attacker to checkout a git repository to an arbitrarily chosen path on the server. If the checkout is done within certain paths within the OpenProject application, upon the next restart of the application, this allows the attacker to inject ruby code into the application.
+
+
+
+As the project identifier cannot be manually edited to any string containing special characters like dots or slashes, this needs to be changed via the SQL injection described above.
+
+
+
+This vulnerability was reported by user [sam91281](https://yeswehack.com/hunters/sam91281) as part of the [YesWeHack.com OpenProject Bug Bounty program](https://yeswehack.com/programs/openproject), sponsored by the European Commission.
+
+
+
+For more information, please see the [GitHub advisory #GHSA-jqhf-rf9x-9rhx](https://github.com/opf/openproject/security/advisories/GHSA-jqhf-rf9x-9rhx)
+
+
+
+### CVE-2026-32703 - Repository files are served with the MIME type allowing them to be used to bypass Content Security Policy
+
+When using the Repositories module in a project, it was possible to access the raw files via the browser with a URL like `/projects/{project}/repository/revisions/{commit_id}/raw/{file}.js.raw`. For those files, the MIME type was detected via the filename extension. For JavaScript and CSS files those files were then served from the same domain name as the application with the correct MIME type for active content and could be used to bypass the Content Security Policy. Together with other areas, where unsanitized HTML was served, this allowed persistent XSS attacks.
+
+
+
+The MIME type detection for Repository files has been removed and files are served as `application/octet-stream` which will block their execution via the Content Security Policy.
+
+
+
+Two places that could be used to abuse this vulnerability have been fixed:
+
+
+
+The Repositories module did not properly escape filenames displayed from repositories. This allowed an attacker with push access into the repository to create commits with filenames that included HTML code that was injected in the page without proper sanitation. This allowed a persisted XSS attack against all members of this project that accessed the repositories page to display a changeset where the maliciously crafted file was deleted.
+
+
+
+When a work package name contains HTML content and the work package is attached to a meeting, the work package name is rendered in the activities feed without proper sanitation.
+
+
+
+All of those vulnerabilities were reported by user [sam91281](https://yeswehack.com/hunters/sam91281) as part of the [YesWeHack.com OpenProject Bug Bounty program](https://yeswehack.com/programs/openproject), sponsored by the European Commission.
+
+
+
+For more information, please see the [GitHub advisory #GHSA-p423-72h4-fjvp](https://github.com/opf/openproject/security/advisories/GHSA-p423-72h4-fjvp)
+
+
+
+
+
+
+## Bug fixes and changes
+
+
+
+
+
+
+
diff --git a/docs/release-notes/17-1-3/README.md b/docs/release-notes/17-1-3/README.md
new file mode 100644
index 00000000000..47562ee6e61
--- /dev/null
+++ b/docs/release-notes/17-1-3/README.md
@@ -0,0 +1,91 @@
+---
+title: OpenProject 17.1.3
+sidebar_navigation:
+ title: 17.1.3
+release_version: 17.1.3
+release_date: 2026-03-16
+---
+
+ # OpenProject 17.1.3
+
+ Release date: 2026-03-16
+
+ We released OpenProject [OpenProject 17.1.3](https://community.openproject.org/versions/2282).
+ The release contains several bug fixes and we recommend updating to the newest version.
+ Below you will find a complete list of all changes and bug fixes.
+
+
+
+## Security fixes
+
+
+
+### CVE-2026-32698 - SQL Injection via Custom Field Name can be chained to Remote Code Execution
+
+OpenProject is vulnerable to an SQL injection attack via a custom field's name. When that custom field was used in a Cost Report, the custom field's name was injected into the SQL query without proper sanitation. This allowed an attacker to execute arbitrary SQL commands during the generation of a Cost Report.
+
+
+
+As custom fields can only be generated by users with full administrator privileges, the attack surface is somewhat reduced.
+
+
+
+Together with another bug in the _Repositories_ module, that used the project identifier without sanitation to generate the checkout path for a git repository in the filesystem, this allowed an attacker to checkout a git repository to an arbitrarily chosen path on the server. If the checkout is done within certain paths within the OpenProject application, upon the next restart of the application, this allows the attacker to inject ruby code into the application.
+
+
+
+As the project identifier cannot be manually edited to any string containing special characters like dots or slashes, this needs to be changed via the SQL injection described above.
+
+
+
+This vulnerability was reported by user [sam91281](https://yeswehack.com/hunters/sam91281) as part of the [YesWeHack.com OpenProject Bug Bounty program](https://yeswehack.com/programs/openproject), sponsored by the European Commission.
+
+
+
+For more information, please see the [GitHub advisory #GHSA-jqhf-rf9x-9rhx](https://github.com/opf/openproject/security/advisories/GHSA-jqhf-rf9x-9rhx)
+
+
+
+### CVE-2026-32703 - Repository files are served with the MIME type allowing them to be used to bypass Content Security Policy
+
+When using the Repositories module in a project, it was possible to access the raw files via the browser with a URL like `/projects/{project}/repository/revisions/{commit_id}/raw/{file}.js.raw`. For those files, the MIME type was detected via the filename extension. For JavaScript and CSS files those files were then served from the same domain name as the application with the correct MIME type for active content and could be used to bypass the Content Security Policy. Together with other areas, where unsanitized HTML was served, this allowed persistent XSS attacks.
+
+
+
+The MIME type detection for Repository files has been removed and files are served as `application/octet-stream` which will block their execution via the Content Security Policy.
+
+
+
+Two places that could be used to abuse this vulnerability have been fixed:
+
+
+
+The Repositories module did not properly escape filenames displayed from repositories. This allowed an attacker with push access into the repository to create commits with filenames that included HTML code that was injected in the page without proper sanitation. This allowed a persisted XSS attack against all members of this project that accessed the repositories page to display a changeset where the maliciously crafted file was deleted.
+
+
+
+When a work package name contains HTML content and the work package is attached to a meeting, the work package name is rendered in the activities feed without proper sanitation.
+
+
+
+All of those vulnerabilities were reported by user [sam91281](https://yeswehack.com/hunters/sam91281) as part of the [YesWeHack.com OpenProject Bug Bounty program](https://yeswehack.com/programs/openproject), sponsored by the European Commission.
+
+
+
+For more information, please see the [GitHub advisory #GHSA-p423-72h4-fjvp](https://github.com/opf/openproject/security/advisories/GHSA-p423-72h4-fjvp)
+
+
+
+
+
+
+## Bug fixes and changes
+
+
+
+
+- Bugfix: Internal error saving project list (when creating new one, or renaming an existing one) \[[#72362](https://community.openproject.org/wp/72362)\]
+- Bugfix: Can't create automatically managed project folder when project name contains forbidden Nextcloud characters \[[#72525](https://community.openproject.org/wp/72525)\]
+
+
+
diff --git a/docs/release-notes/17-2-1/README.md b/docs/release-notes/17-2-1/README.md
new file mode 100644
index 00000000000..66f0cf45c98
--- /dev/null
+++ b/docs/release-notes/17-2-1/README.md
@@ -0,0 +1,98 @@
+---
+title: OpenProject 17.2.1
+sidebar_navigation:
+ title: 17.2.1
+release_version: 17.2.1
+release_date: 2026-03-16
+---
+
+ # OpenProject 17.2.1
+
+ Release date: 2026-03-16
+
+ We released OpenProject [OpenProject 17.2.1](https://community.openproject.org/versions/2283).
+ The release contains several bug fixes and we recommend updating to the newest version.
+ Below you will find a complete list of all changes and bug fixes.
+
+
+
+## Security fixes
+
+
+
+### CVE-2026-32698 - SQL Injection via Custom Field Name can be chained to Remote Code Execution
+
+OpenProject is vulnerable to an SQL injection attack via a custom field's name. When that custom field was used in a Cost Report, the custom field's name was injected into the SQL query without proper sanitation. This allowed an attacker to execute arbitrary SQL commands during the generation of a Cost Report.
+
+
+
+As custom fields can only be generated by users with full administrator privileges, the attack surface is somewhat reduced.
+
+
+
+Together with another bug in the _Repositories_ module, that used the project identifier without sanitation to generate the checkout path for a git repository in the filesystem, this allowed an attacker to checkout a git repository to an arbitrarily chosen path on the server. If the checkout is done within certain paths within the OpenProject application, upon the next restart of the application, this allows the attacker to inject ruby code into the application.
+
+
+
+As the project identifier cannot be manually edited to any string containing special characters like dots or slashes, this needs to be changed via the SQL injection described above.
+
+
+
+This vulnerability was reported by user [sam91281](https://yeswehack.com/hunters/sam91281) as part of the [YesWeHack.com OpenProject Bug Bounty program](https://yeswehack.com/programs/openproject), sponsored by the European Commission.
+
+
+
+For more information, please see the [GitHub advisory #GHSA-jqhf-rf9x-9rhx](https://github.com/opf/openproject/security/advisories/GHSA-jqhf-rf9x-9rhx)
+
+
+
+### CVE-2026-32703 - Repository files are served with the MIME type allowing them to be used to bypass Content Security Policy
+
+When using the Repositories module in a project, it was possible to access the raw files via the browser with a URL like `/projects/{project}/repository/revisions/{commit_id}/raw/{file}.js.raw`. For those files, the MIME type was detected via the filename extension. For JavaScript and CSS files those files were then served from the same domain name as the application with the correct MIME type for active content and could be used to bypass the Content Security Policy. Together with other areas, where unsanitized HTML was served, this allowed persistent XSS attacks.
+
+
+
+The MIME type detection for Repository files has been removed and files are served as `application/octet-stream` which will block their execution via the Content Security Policy.
+
+
+
+Two places that could be used to abuse this vulnerability have been fixed:
+
+
+
+The Repositories module did not properly escape filenames displayed from repositories. This allowed an attacker with push access into the repository to create commits with filenames that included HTML code that was injected in the page without proper sanitation. This allowed a persisted XSS attack against all members of this project that accessed the repositories page to display a changeset where the maliciously crafted file was deleted.
+
+
+
+When a work package name contains HTML content and the work package is attached to a meeting, the work package name is rendered in the activities feed without proper sanitation.
+
+
+
+All of those vulnerabilities were reported by user [sam91281](https://yeswehack.com/hunters/sam91281) as part of the [YesWeHack.com OpenProject Bug Bounty program](https://yeswehack.com/programs/openproject), sponsored by the European Commission.
+
+
+
+For more information, please see the [GitHub advisory #GHSA-p423-72h4-fjvp](https://github.com/opf/openproject/security/advisories/GHSA-p423-72h4-fjvp)
+
+
+
+
+
+
+## Bug fixes and changes
+
+
+
+
+- Bugfix: Screenshot overlaps with CKeditor tool bar when scrolling \[[#72678](https://community.openproject.org/wp/72678)\]
+- Bugfix: Jira import: error when imported type is the same as on OP (with mandatory custom fields) \[[#72854](https://community.openproject.org/wp/72854)\]
+- Bugfix: Nextcloud: When creating an AMPF folder fails, other folders are also not created \[[#72940](https://community.openproject.org/wp/72940)\]
+- Bugfix: Send test email fails when using SMTP and TLS \[[#73099](https://community.openproject.org/wp/73099)\]
+- Bugfix: curl removed from openproject/openproject:17-slim in 17.2.0 \[[#73142](https://community.openproject.org/wp/73142)\]
+
+
+
+
+## Contributions
+A big thanks to our Community members for reporting bugs and helping us identify and provide fixes.
+This release, special thanks for reporting and finding bugs go to Martin Pfister.
diff --git a/docs/release-notes/README.md b/docs/release-notes/README.md
index e7232e35c13..87a363daf2f 100644
--- a/docs/release-notes/README.md
+++ b/docs/release-notes/README.md
@@ -13,6 +13,20 @@ Stay up to date and get an overview of the new features included in the releases
+## 17.2.1
+
+Release date: 2026-03-16
+
+[Release Notes](17-2-1/)
+
+
+## 17.1.3
+
+Release date: 2026-03-16
+
+[Release Notes](17-1-3/)
+
+
## 17.2.0
Release date: 2026-03-11
@@ -25,6 +39,13 @@ Release date: 2026-02-26
[Release Notes](17-1-2/)
+
+## 17.0.6
+
+Release date: 2026-03-16
+
+[Release Notes](17-0-6/)
+
## 17.0.5
Release date: 2026-02-26
diff --git a/lib/open_project/remove_ascii_control_characters.rb b/lib/open_project/remove_ascii_control_characters.rb
new file mode 100644
index 00000000000..0e189ae6b1d
--- /dev/null
+++ b/lib/open_project/remove_ascii_control_characters.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module OpenProject
+ # Strips ASCII control characters (0x00–0x1F, 0x7F) from a string.
+ # Designed for use with ActiveRecord's `normalizes` API:
+ #
+ # normalizes :name, with: OpenProject::RemoveAsciiControlCharacters
+ #
+ RemoveAsciiControlCharacters = ->(value) { value.is_a?(String) ? value.gsub(/[\x00-\x1F\x7F]/, "") : value }
+end
diff --git a/lib/open_project/scm/adapters/git.rb b/lib/open_project/scm/adapters/git.rb
index cca3094bd25..c10c09ba002 100644
--- a/lib/open_project/scm/adapters/git.rb
+++ b/lib/open_project/scm/adapters/git.rb
@@ -201,9 +201,14 @@ module OpenProject
end
def checkout_path
- Pathname(OpenProject::Configuration.scm_local_checkout_path)
- .join(@identifier)
- .expand_path
+ root = Pathname(OpenProject::Configuration.scm_local_checkout_path).expand_path.to_s
+ path = Pathname(root).join(@identifier).expand_path.to_s
+
+ unless path.start_with?("#{root}/")
+ raise ArgumentError, "Checkout path escapes the configured root directory"
+ end
+
+ Pathname(path)
end
def checkout_uri
diff --git a/lib/open_project/scm/manageable_repository.rb b/lib/open_project/scm/manageable_repository.rb
index 4ed2ce65301..f5cc07db129 100644
--- a/lib/open_project/scm/manageable_repository.rb
+++ b/lib/open_project/scm/manageable_repository.rb
@@ -111,7 +111,14 @@ module OpenProject
# Used only in the creation of a repository, at a later point
# in time, it is referred to in the root_url
def managed_repository_path
- File.join(self.class.managed_root, repository_identifier)
+ root = File.expand_path(self.class.managed_root)
+ path = File.expand_path(File.join(root, repository_identifier))
+
+ unless path.start_with?("#{root}/")
+ raise ArgumentError, "Repository path escapes the configured managed root directory"
+ end
+
+ path
end
##
diff --git a/modules/meeting/lib/open_project/journal_formatter/meeting_work_package_id.rb b/modules/meeting/lib/open_project/journal_formatter/meeting_work_package_id.rb
index 0edb7cc83f5..e8622bd6f13 100644
--- a/modules/meeting/lib/open_project/journal_formatter/meeting_work_package_id.rb
+++ b/modules/meeting/lib/open_project/journal_formatter/meeting_work_package_id.rb
@@ -1,4 +1,5 @@
# frozen_string_literal: true
+
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
@@ -44,8 +45,8 @@ class OpenProject::JournalFormatter::MeetingWorkPackageId < JournalFormatter::Ba
old = visible(values.first)
I18n.t(:"activity.item.meeting_agenda_item.work_package.updated#{html}",
- value: new ? new.name : I18n.t(:label_agenda_item_undisclosed_wp, id: values.last),
- old_value: old ? old.name : I18n.t(:label_agenda_item_undisclosed_wp, id: values.first))
+ value: new ? h(new.name) : I18n.t(:label_agenda_item_undisclosed_wp, id: values.last),
+ old_value: old ? h(old.name) : I18n.t(:label_agenda_item_undisclosed_wp, id: values.first))
end
def visible(work_package_id)
diff --git a/modules/meeting/spec/lib/open_project/journal_formatter/meeting_work_package_id_spec.rb b/modules/meeting/spec/lib/open_project/journal_formatter/meeting_work_package_id_spec.rb
new file mode 100644
index 00000000000..05984fb0fb2
--- /dev/null
+++ b/modules/meeting/spec/lib/open_project/journal_formatter/meeting_work_package_id_spec.rb
@@ -0,0 +1,99 @@
+# 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.
+#++
+
+require "spec_helper"
+
+RSpec.describe OpenProject::JournalFormatter::MeetingWorkPackageId do
+ let(:instance) { described_class.new(build_stubbed(:journal)) }
+
+ let(:project) { create(:project) }
+ let(:old_work_package) { create(:work_package, project:, subject: "Old task") }
+ let(:new_work_package) { create(:work_package, project:, subject: "New task") }
+
+ describe "#render" do
+ context "when both work packages are visible" do
+ before do
+ allow(WorkPackage).to receive(:visible).and_return(WorkPackage.where(id: [old_work_package.id, new_work_package.id]))
+ end
+
+ it "renders work package names in HTML mode" do
+ result = instance.render("work_package_id", [old_work_package.id, new_work_package.id], html: true)
+
+ expect(result).to include("Old task")
+ expect(result).to include("New task")
+ end
+
+ it "renders work package names in plain text mode" do
+ result = instance.render("work_package_id", [old_work_package.id, new_work_package.id], html: false)
+
+ expect(result).to include("Old task")
+ expect(result).to include("New task")
+ end
+ end
+
+ context "when work packages are not visible" do
+ before do
+ allow(WorkPackage).to receive(:visible).and_return(WorkPackage.none)
+ end
+
+ it "renders undisclosed work package text with the ID" do
+ result = instance.render("work_package_id", [old_work_package.id, new_work_package.id], html: true)
+
+ expect(result).to include(old_work_package.id.to_s)
+ expect(result).to include(new_work_package.id.to_s)
+ expect(result).not_to include("Old task")
+ expect(result).not_to include("New task")
+ end
+ end
+
+ context "when work package names contain HTML" do
+ let(:old_work_package) { create(:work_package, project:, subject: "Safe task") }
+ let(:new_work_package) { create(:work_package, project:, subject: "
") }
+
+ before do
+ allow(WorkPackage).to receive(:visible).and_return(WorkPackage.where(id: [old_work_package.id, new_work_package.id]))
+ end
+
+ it "escapes HTML in work package names" do
+ result = instance.render("work_package_id", [old_work_package.id, new_work_package.id], html: true)
+
+ expect(result).not_to include("
")
+ expect(result).to include("<script>")
+ end
+
+ it "preserves html_safe text content" do
+ safe_text = 'file.txt'.html_safe
+ result = helper.changes_tree_li_element("A", safe_text, "change change-A")
+
+ expect(result).to include('file.txt')
+ end
+
+ it "escapes malicious content in the style parameter" do
+ result = helper.changes_tree_li_element("A", "file.txt", 'change" onclick="alert(1)')
+
+ expect(result).not_to include('onclick="alert(1)"')
+ expect(result).to include("onclick="alert(1)")
+ end
+
+ it "sets the correct icon class for each action" do
+ expect(helper.changes_tree_li_element("A", "f", "s")).to include("icon-add")
+ expect(helper.changes_tree_li_element("D", "f", "s")).to include("icon-delete")
+ expect(helper.changes_tree_li_element("C", "f", "s")).to include("icon-copy")
+ expect(helper.changes_tree_li_element("R", "f", "s")).to include("icon-rename")
+ expect(helper.changes_tree_li_element("M", "f", "s")).to include("icon-arrow-left-right")
+ end
+
+ it "sets the title attribute from the action" do
+ result = helper.changes_tree_li_element("D", "file.txt", "change")
+
+ expect(result).to include("title=\"#{I18n.t(:label_deleted)}\"")
+ end
+ end
+
+ describe "#render_changes_tree" do
+ def make_change(path:, action:, revision: nil, from_path: nil)
+ instance_double(Change, path:, action:, revision:, from_path:).tap do |c|
+ allow(c).to receive(:action=)
+ end
+ end
+
+ it "returns empty string for nil tree" do
+ expect(helper.render_changes_tree(nil)).to eq("")
+ end
+
+ context "with a deleted file containing a malicious name" do
+ let(:tree) do
+ {
+ '/">
' => {
+ c: make_change(path: '/">
', action: "D")
+ }
+ }
+ end
+
+ it "escapes the filename" do
+ result = helper.render_changes_tree(tree)
+ doc = Nokogiri::HTML.fragment(result)
+
+ expect(doc.at_css("img")).not_to be_present
+ li = doc.at_css("li")
+ expect(li.text).to include("
")
+ end
+ end
+
+ context "with an added file" do
+ let(:tree) do
+ {
+ "/added_file.txt" => {
+ c: make_change(path: "/added_file.txt", action: "A")
+ }
+ }
+ end
+
+ it "renders a link to the file" do
+ result = helper.render_changes_tree(tree)
+
+ expect(result).to include(" {
+ c: make_change(path: "/modified.txt", action: "M")
+ }
+ }
+ end
+
+ it "renders a diff link" do
+ result = helper.render_changes_tree(tree)
+
+ expect(result).to include(I18n.t(:label_diff))
+ expect(result).to include("icon-arrow-left-right")
+ end
+ end
+
+ context "with a file with revision" do
+ let(:tree) do
+ {
+ "/file.txt" => {
+ c: make_change(path: "/file.txt", action: "A", revision: "abc123")
+ }
+ }
+ end
+
+ it "includes the escaped revision" do
+ result = helper.render_changes_tree(tree)
+
+ expect(result).to include("abc123")
+ end
+ end
+
+ context "with a file with a malicious revision" do
+ let(:tree) do
+ {
+ "/file.txt" => {
+ c: make_change(path: "/file.txt", action: "A", revision: '')
+ }
+ }
+ end
+
+ it "escapes the revision" do
+ result = helper.render_changes_tree(tree)
+
+ expect(result).not_to include("