diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-07-17 19:30:43 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-07-17 19:30:43 +0200 |
| commit | 04450e10866fc56890426525062fd5cdf56c4300 (patch) | |
| tree | 952fb91c85aa9e03f9b7d3de93ee5af42b30324d | |
| parent | 5221c5400e193a64ab607d7de7c0450f5f15cd55 (diff) | |
Allowlist aggregate order columns and template names
Page.aggregate interpolated order_by into SQL unchecked while
already allowlisting order_direction; the column is now normalized
and checked against the sortable columns, falling back to pages.id.
Its values arrive from editor-authored aggregate shortcodes, so
this was editor-gated, but the asymmetry was wrong regardless.
template_name and default_template_name now validate inclusion in
Page.custom_templates -- names render as filesystem paths, so only
names actually present in the template directory are acceptable.
Validated only on change: legacy rows whose template file has since
vanished stay saveable, and valid_template's render-time fallback
to standard_template continues to cover them. Two tests that wrote
fabricated template names through the front door now arrange their
state correctly (update_column for the stale-name fallback test, a
real template for the update-persists test).
| -rw-r--r-- | app/models/node.rb | 5 | ||||
| -rw-r--r-- | app/models/page.rb | 15 | ||||
| -rw-r--r-- | test/controllers/content_controller_test.rb | 3 | ||||
| -rw-r--r-- | test/controllers/nodes_controller_test.rb | 14 | ||||
| -rw-r--r-- | test/models/node_test.rb | 9 | ||||
| -rw-r--r-- | test/models/page_test.rb | 32 |
6 files changed, 73 insertions, 5 deletions
diff --git a/app/models/node.rb b/app/models/node.rb index 4f585e1..70ed4da 100644 --- a/app/models/node.rb +++ b/app/models/node.rb | |||
| @@ -22,6 +22,11 @@ class Node < ApplicationRecord | |||
| 22 | validates_uniqueness_of :slug, :scope => :parent_id, :unless => -> { parent_id.nil? } | 22 | validates_uniqueness_of :slug, :scope => :parent_id, :unless => -> { parent_id.nil? } |
| 23 | validates_presence_of :parent_id, :unless => -> { Node.root.nil? } | 23 | validates_presence_of :parent_id, :unless => -> { Node.root.nil? } |
| 24 | 24 | ||
| 25 | validates :default_template_name, | ||
| 26 | :inclusion => { :in => ->(_) { Page.custom_templates } }, | ||
| 27 | :allow_nil => true, | ||
| 28 | :if => :default_template_name_changed? | ||
| 29 | |||
| 25 | # Class methods | 30 | # Class methods |
| 26 | 31 | ||
| 27 | # Returns a page for a given node. If no revision is supplied, it returns | 32 | # Returns a page for a given node. If no revision is supplied, it returns |
diff --git a/app/models/page.rb b/app/models/page.rb index e5a8d9d..be21ff7 100644 --- a/app/models/page.rb +++ b/app/models/page.rb | |||
| @@ -11,6 +11,16 @@ class Page < ApplicationRecord | |||
| 11 | 11 | ||
| 12 | translates :title, :abstract, :body # Globalize2 | 12 | translates :title, :abstract, :body # Globalize2 |
| 13 | 13 | ||
| 14 | # Template names render as filesystem paths; only names actually | ||
| 15 | # present in the public template directory are acceptable. Validated | ||
| 16 | # only on change so legacy rows whose template file has since | ||
| 17 | # vanished stay saveable -- valid_template already falls back to | ||
| 18 | # standard_template for those at render time. | ||
| 19 | validates :template_name, | ||
| 20 | :inclusion => { :in => ->(_) { Page.custom_templates } }, | ||
| 21 | :allow_nil => true, | ||
| 22 | :if => :template_name_changed? | ||
| 23 | |||
| 14 | # Associations | 24 | # Associations |
| 15 | belongs_to :node, optional: true | 25 | belongs_to :node, optional: true |
| 16 | belongs_to :user, optional: true | 26 | belongs_to :user, optional: true |
| @@ -77,7 +87,10 @@ class Page < ApplicationRecord | |||
| 77 | .paginate(:page => page, :per_page => options[:limit]) | 87 | .paginate(:page => page, :per_page => options[:limit]) |
| 78 | end | 88 | end |
| 79 | 89 | ||
| 80 | scope.order("#{options[:order_by]} #{direction}") | 90 | column = options[:order_by].to_s.sub(/\Apages\./, "") |
| 91 | column = "id" unless %w[id published_at created_at updated_at].include?(column) | ||
| 92 | |||
| 93 | scope.order("pages.#{column} #{direction}") | ||
| 81 | .paginate(:page => page, :per_page => options[:limit]) | 94 | .paginate(:page => page, :per_page => options[:limit]) |
| 82 | end | 95 | end |
| 83 | 96 | ||
diff --git a/test/controllers/content_controller_test.rb b/test/controllers/content_controller_test.rb index e8bc55f..2449a0c 100644 --- a/test/controllers/content_controller_test.rb +++ b/test/controllers/content_controller_test.rb | |||
| @@ -73,8 +73,7 @@ class ContentControllerTest < ActionController::TestCase | |||
| 73 | def test_nonexistant_custom_template_defaults_to_standard_template | 73 | def test_nonexistant_custom_template_defaults_to_standard_template |
| 74 | new_node = create_node_under_root "fnord" | 74 | new_node = create_node_under_root "fnord" |
| 75 | draft = find_or_create_draft(new_node, @user1) | 75 | draft = find_or_create_draft(new_node, @user1) |
| 76 | draft.template_name = "huchibu" | 76 | draft.update_column(:template_name, "huchibu") |
| 77 | draft.save | ||
| 78 | new_node.publish_draft! | 77 | new_node.publish_draft! |
| 79 | 78 | ||
| 80 | get :render_page, params: { :locale => 'de', :page_path => ["fnord"] } | 79 | get :render_page, params: { :locale => 'de', :page_path => ["fnord"] } |
diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb index 5b66bd3..b0d7416 100644 --- a/test/controllers/nodes_controller_test.rb +++ b/test/controllers/nodes_controller_test.rb | |||
| @@ -140,7 +140,7 @@ class NodesControllerTest < ActionController::TestCase | |||
| 140 | :page => { | 140 | :page => { |
| 141 | :title => "Hello", | 141 | :title => "Hello", |
| 142 | :body => "There", | 142 | :body => "There", |
| 143 | :template_name => "Foobar" | 143 | :template_name => "title_only" |
| 144 | } | 144 | } |
| 145 | } | 145 | } |
| 146 | 146 | ||
| @@ -148,7 +148,17 @@ class NodesControllerTest < ActionController::TestCase | |||
| 148 | test_node.reload | 148 | test_node.reload |
| 149 | assert_equal "Hello", test_node.head.title | 149 | assert_equal "Hello", test_node.head.title |
| 150 | assert_equal "There", test_node.head.body | 150 | assert_equal "There", test_node.head.body |
| 151 | assert_equal "Foobar", test_node.head.template_name | 151 | assert_equal "title_only", test_node.head.template_name |
| 152 | end | ||
| 153 | |||
| 154 | def test_update_rejects_a_template_name_not_on_disk | ||
| 155 | test_node = Node.root.children.create! :slug => "test_node" | ||
| 156 | login_as :quentin | ||
| 157 | put :update, params: { :id => test_node.id, | ||
| 158 | :page => { :title => "Hello", :template_name => "Foobar" } } | ||
| 159 | |||
| 160 | test_node.reload | ||
| 161 | assert_not_equal "Foobar", test_node.draft&.template_name | ||
| 152 | end | 162 | end |
| 153 | 163 | ||
| 154 | test "publish draft with staged_slug unqueal slug" do | 164 | test "publish draft with staged_slug unqueal slug" do |
diff --git a/test/models/node_test.rb b/test/models/node_test.rb index c8e49e5..4401651 100644 --- a/test/models/node_test.rb +++ b/test/models/node_test.rb | |||
| @@ -761,4 +761,13 @@ class NodeTest < ActiveSupport::TestCase | |||
| 761 | assert_equal "Second", action.metadata.dig("title", "from") | 761 | assert_equal "Second", action.metadata.dig("title", "from") |
| 762 | assert_equal "First", action.metadata.dig("title", "to") | 762 | assert_equal "First", action.metadata.dig("title", "to") |
| 763 | end | 763 | end |
| 764 | |||
| 765 | test "default_template_name rejects values not present in the template directory" do | ||
| 766 | node = Node.root.children.build(:slug => "template_guard_test", | ||
| 767 | :default_template_name => "../../layouts/admin") | ||
| 768 | assert_not node.valid? | ||
| 769 | |||
| 770 | node.default_template_name = "standard_template" | ||
| 771 | assert node.valid? | ||
| 772 | end | ||
| 764 | end | 773 | end |
diff --git a/test/models/page_test.rb b/test/models/page_test.rb index edb7c37..58794d5 100644 --- a/test/models/page_test.rb +++ b/test/models/page_test.rb | |||
| @@ -370,4 +370,36 @@ class PageTest < ActiveSupport::TestCase | |||
| 370 | assert en_entry[:changed] | 370 | assert en_entry[:changed] |
| 371 | refute en_entry[:exists_there] | 371 | refute en_entry[:exists_there] |
| 372 | end | 372 | end |
| 373 | |||
| 374 | test "aggregate ignores order_by values outside the allowlist" do | ||
| 375 | sql = Page.aggregate(:order_by => "pages.id; DROP TABLE pages--").to_sql | ||
| 376 | |||
| 377 | assert_not_includes sql, "DROP" | ||
| 378 | assert_includes sql, "pages.id ASC" | ||
| 379 | end | ||
| 380 | |||
| 381 | test "aggregate accepts allowlisted order columns, bare or prefixed" do | ||
| 382 | assert_includes Page.aggregate(:order_by => "published_at").to_sql, | ||
| 383 | "pages.published_at ASC" | ||
| 384 | assert_includes Page.aggregate(:order_by => "pages.published_at").to_sql, | ||
| 385 | "pages.published_at ASC" | ||
| 386 | end | ||
| 387 | |||
| 388 | test "template_name rejects values not present in the template directory" do | ||
| 389 | page = Page.create!(:title => "Template guard") | ||
| 390 | |||
| 391 | page.template_name = "../../partials/_article" | ||
| 392 | assert_not page.valid? | ||
| 393 | |||
| 394 | page.template_name = "standard_template" | ||
| 395 | assert page.valid? | ||
| 396 | end | ||
| 397 | |||
| 398 | test "a stale legacy template_name does not block unrelated saves" do | ||
| 399 | page = Page.create!(:title => "Stale template") | ||
| 400 | page.update_column(:template_name, "long_deleted_template") | ||
| 401 | |||
| 402 | page.reload | ||
| 403 | assert page.update(:abstract => "still saveable") | ||
| 404 | end | ||
| 373 | end | 405 | end |
