summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-07-17 19:30:43 +0200
committererdgeist <erdgeist@erdgeist.org>2026-07-17 19:30:43 +0200
commit04450e10866fc56890426525062fd5cdf56c4300 (patch)
tree952fb91c85aa9e03f9b7d3de93ee5af42b30324d
parent5221c5400e193a64ab607d7de7c0450f5f15cd55 (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.rb5
-rw-r--r--app/models/page.rb15
-rw-r--r--test/controllers/content_controller_test.rb3
-rw-r--r--test/controllers/nodes_controller_test.rb14
-rw-r--r--test/models/node_test.rb9
-rw-r--r--test/models/page_test.rb32
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
764end 773end
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
373end 405end