Skip to content

Commit ff42c71

Browse files
committed
feat(css): enhance CSS rendering and sanitization tests; add factory for CSS blocks
1 parent ed704ad commit ff42c71

File tree

4 files changed

+428
-3
lines changed

4 files changed

+428
-3
lines changed
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11

22
<% if css.content.present? %>
3-
<%= content_tag :style, sanitize_block_css(css.content), type: 'text/css', id: dom_id(css) %>
3+
<%= content_tag :style, sanitize_block_css(css.content).html_safe, type: 'text/css', id: dom_id(css) %>
44
<% end %>
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# frozen_string_literal: true
2+
3+
FactoryBot.define do
4+
factory :better_together_content_css, class: 'BetterTogether::Content::Css' do
5+
association :creator, factory: :better_together_person
6+
privacy { 'public' }
7+
identifier { "css-block-#{SecureRandom.hex(4)}" }
8+
9+
transient do
10+
content_text { '.my-class { color: red; }' }
11+
end
12+
13+
after(:build) do |css_block, evaluator|
14+
css_block.content = evaluator.content_text if evaluator.content_text.present?
15+
end
16+
17+
after(:create) do |css_block, evaluator|
18+
if evaluator.content_text.present?
19+
css_block.update(content: evaluator.content_text)
20+
end
21+
end
22+
23+
trait :with_complex_css do
24+
transient do
25+
content_text do
26+
<<~CSS
27+
.leaflet-top, .leaflet-bottom { z-index: 999; }
28+
.notification form[action*="mark_as_read"] .btn[type="submit"] { z-index: 1200; }
29+
.card.journey-stage > .card-body { max-height: 50vh; }
30+
@media only screen and (min-width: 768px) {
31+
.hero-heading { font-size: 3em; }
32+
}
33+
CSS
34+
end
35+
end
36+
end
37+
38+
trait :with_dangerous_css do
39+
transient do
40+
content_text { 'width: expression(alert("XSS")); background: url(javascript:void(0));' }
41+
end
42+
end
43+
44+
trait :empty do
45+
transient do
46+
content_text { nil }
47+
end
48+
end
49+
end
50+
end

spec/helpers/better_together/content/blocks_helper_spec.rb

Lines changed: 141 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,147 @@
1414
# end
1515
module BetterTogether
1616
RSpec.describe Content::BlocksHelper do
17-
it 'exists' do
18-
expect(described_class).to be # rubocop:todo RSpec/Be
17+
describe '#sanitize_block_css' do
18+
context 'with safe CSS' do
19+
it 'returns the CSS unchanged' do
20+
css = '.my-class { color: red; }'
21+
expect(helper.sanitize_block_css(css)).to eq(css)
22+
end
23+
24+
it 'preserves attribute selectors with quotes' do
25+
css = '.notification form[action*="mark_as_read"] .btn[type="submit"] { z-index: 1200; }'
26+
expect(helper.sanitize_block_css(css)).to eq(css)
27+
end
28+
29+
it 'preserves child selectors' do
30+
css = '.card.journey-stage > .card-body { max-height: 50vh; }'
31+
expect(helper.sanitize_block_css(css)).to eq(css)
32+
end
33+
34+
it 'preserves pseudo-selectors with quotes' do
35+
css = '.trix-content a[href]:not([href*="example.com"])::after { content: "\f35d"; }'
36+
expect(helper.sanitize_block_css(css)).to eq(css)
37+
end
38+
39+
it 'preserves media queries' do
40+
css = '@media only screen and (min-width: 768px) { .hero-heading { font-size: 3em; } }'
41+
expect(helper.sanitize_block_css(css)).to eq(css)
42+
end
43+
44+
it 'preserves CSS custom properties' do
45+
css = '.navbar { --bs-navbar-toggler-padding-x: 0.25rem; }'
46+
expect(helper.sanitize_block_css(css)).to eq(css)
47+
end
48+
49+
it 'preserves important declarations' do
50+
css = '.element { color: #404de0 !important; }'
51+
expect(helper.sanitize_block_css(css)).to eq(css)
52+
end
53+
54+
it 'preserves multiple selectors with various characters' do
55+
css = '.content_rich_text a, trix-editor a, .trix-content a { text-decoration: none; }'
56+
expect(helper.sanitize_block_css(css)).to eq(css)
57+
end
58+
end
59+
60+
context 'with dangerous CSS patterns' do
61+
it 'removes expression() calls' do
62+
css = 'width: expression(alert("XSS"));'
63+
sanitized = helper.sanitize_block_css(css)
64+
expect(sanitized).not_to include('expression(')
65+
expect(sanitized).to eq('width: alert("XSS"));')
66+
end
67+
68+
it 'removes expression() with different casing' do
69+
css = 'width: ExPrEsSiOn(alert("XSS"));'
70+
sanitized = helper.sanitize_block_css(css)
71+
expect(sanitized).not_to match(/expression\s*\(/i)
72+
end
73+
74+
it 'removes javascript: URLs in url()' do
75+
css = 'background: url(javascript:alert("XSS"));'
76+
sanitized = helper.sanitize_block_css(css)
77+
expect(sanitized).not_to include('javascript:')
78+
# NOTE: The regex replaces the entire url(...) content but preserves closing paren
79+
expect(sanitized).to eq('background: url(""));')
80+
end
81+
82+
it 'preserves safe url() calls' do
83+
css = 'background: url("/images/bg.png");'
84+
expect(helper.sanitize_block_css(css)).to eq(css)
85+
end
86+
87+
it 'removes multiple dangerous patterns' do
88+
css = 'width: expression(alert(1)); background: url(javascript:void(0));'
89+
sanitized = helper.sanitize_block_css(css)
90+
expect(sanitized).not_to include('expression(')
91+
expect(sanitized).not_to include('javascript:')
92+
end
93+
end
94+
95+
context 'with edge cases' do
96+
it 'returns empty string for nil input' do
97+
expect(helper.sanitize_block_css(nil)).to eq('')
98+
end
99+
100+
it 'returns empty string for blank input' do
101+
expect(helper.sanitize_block_css('')).to eq('')
102+
# NOTE: sanitize_block_css treats whitespace-only as blank
103+
expect(helper.sanitize_block_css(' ')).to eq('')
104+
end
105+
106+
it 'handles very long CSS strings' do
107+
long_css = '.class { color: red; }' * 1000
108+
expect(helper.sanitize_block_css(long_css)).to eq(long_css)
109+
end
110+
end
111+
end
112+
113+
describe '#sanitize_block_html' do
114+
it 'allows safe HTML tags' do
115+
html = '<p>Hello <strong>world</strong></p>'
116+
expect(helper.sanitize_block_html(html)).to eq(html)
117+
end
118+
119+
it 'removes script tags' do
120+
html = '<p>Hello</p><script>alert("XSS")</script>'
121+
sanitized = helper.sanitize_block_html(html)
122+
expect(sanitized).not_to include('<script>')
123+
expect(sanitized).to include('<p>Hello</p>')
124+
end
125+
126+
it 'allows whitelisted attributes' do
127+
html = '<a href="http://example.com" class="link" target="_blank">Link</a>'
128+
expect(helper.sanitize_block_html(html)).to eq(html)
129+
end
130+
end
131+
132+
describe '#acceptable_image_file_types' do
133+
it 'returns valid image content types' do
134+
types = helper.acceptable_image_file_types
135+
expect(types).to be_an(Array)
136+
expect(types).to include('image/jpeg', 'image/png', 'image/gif')
137+
end
138+
end
139+
140+
describe '#temp_id_for' do
141+
let(:persisted_model) { double('Model', persisted?: true, id: 123) }
142+
let(:new_model) { double('Model', persisted?: false) }
143+
144+
it 'returns model id for persisted models' do
145+
expect(helper.temp_id_for(persisted_model)).to eq(123)
146+
end
147+
148+
it 'returns temp_id for new models' do
149+
temp_id = 'temp-uuid-123'
150+
expect(helper.temp_id_for(new_model, temp_id: temp_id)).to eq(temp_id)
151+
end
152+
153+
it 'generates a UUID for new models by default' do
154+
result = helper.temp_id_for(new_model)
155+
expect(result).to be_a(String)
156+
expect(result).to match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)
157+
end
19158
end
20159
end
21160
end

0 commit comments

Comments
 (0)