Skip to content

Commit ed21a60

Browse files
committed
fix: breaking/missing svg nodes
1 parent 82437c0 commit ed21a60

File tree

5 files changed

+158
-4
lines changed

5 files changed

+158
-4
lines changed

app/javascript/src/components/structureEditor/StructureEditorModal.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ export default class StructureEditorModal extends React.Component {
278278

279279
async handleSaveStructureKet2(structure, editor) {
280280
try {
281-
const molfile = await structure.editor.getMolfile();
281+
const molfile = await structure.editor.getMolfile('V2000');
282282
const imgfile = await structure.editor.generateImage(molfile, { outputFormat: 'svg' });
283283
const text = await imgfile.text();
284284
const updatedSvg = await transformSvgIdsAndReferences(text);

app/javascript/src/utilities/SvgUtils.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ const transformSvgIdsAndReferences = async (svgText) => {
1818
elementsWithId.forEach((el) => {
1919
const originalId = el.getAttribute('id');
2020
if (mappedIdPattern.test(originalId)) return svgText;
21+
22+
if (!originalId.includes('glyph')) return;
23+
24+
// Otherwise, add the suffix
2125
const uniqueId = `${originalId}_${uniqueSuffix}`;
2226
idMap[originalId] = uniqueId;
2327
el.setAttribute('id', uniqueId);
@@ -35,4 +39,4 @@ const transformSvgIdsAndReferences = async (svgText) => {
3539
return svg;
3640
};
3741

38-
export { transformSvgIdsAndReferences, mappedIdPattern };
42+
export { transformSvgIdsAndReferences, mappedIdPattern };

lib/chemotion/sanitizer.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ def base_scrub_ml(fragment, type: :xml, encoding: nil, remap_glyph_ids: false)
2828
Loofah.scrub_xml_fragment(result, :strip)
2929
when :html
3030
Loofah.scrub_html5_fragment(result, :strip)
31+
when :svg
32+
Chemotion::SvgSanitizer.sanitize(result)
3133
else
3234
Loofah.scrub_fragment(result, :strip)
3335
end.to_s
@@ -85,9 +87,8 @@ def map_defs_ids
8587
@current_node.xpath('svg:defs//svg:g[@id]', svg_namespace).each do |element|
8688
# Check if the element has an id attribute or skip if it has a unique id ending
8789
# (from SecureRandom.hex(4))
88-
next if !element['id'] || element['id'].match?(/_[0-9a-f]{8}$/)
90+
next if !element['id'] || element['id'].exclude?('glyph') || element['id'].match?(/_[0-9a-f]{8}$/)
8991

90-
# Generate a new id, store the mapping, and update the element's id
9192
new_id = "#{element['id']}_#{SecureRandom.hex(4)}"
9293
@id_map[element['id']] = new_id
9394
element['id'] = new_id

lib/chemotion/svg_sanitizer.rb

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# frozen_string_literal: true
2+
3+
require 'nokogiri'
4+
5+
module Chemotion
6+
class SvgSanitizer
7+
BLOCKED_TAGS = %w[
8+
script foreignobject iframe embed object audio video style link meta
9+
].freeze
10+
11+
BLOCKED_ATTRIBUTES = %w[
12+
onabort onclick ondblclick onerror onfocus onkeydown onkeypress
13+
onkeyup onload onmousedown onmousemove onmouseout onmouseover
14+
onmouseup onreset onresize onscroll onselect onsubmit onunload
15+
onchange oninput onanimationstart onanimationend onanimationiteration
16+
onblur oncancel oncanplay oncontextmenu
17+
].freeze
18+
19+
BLOCKED_SCHEMES = %w[javascript vbscript livescript mocha data file about mhtml].freeze
20+
21+
class << self
22+
def sanitize(svg_string)
23+
doc = parse_svg(svg_string)
24+
clean_document(doc)
25+
format_output(doc)
26+
end
27+
28+
private
29+
30+
def parse_svg(svg_string)
31+
Nokogiri::XML(svg_string, &:recover)
32+
end
33+
34+
def clean_document(doc)
35+
doc.traverse { |node| process_node(node) }
36+
end
37+
38+
def process_node(node)
39+
return unless node.element?
40+
41+
return node.remove if blocked_tag?(node)
42+
43+
clean_attributes(node)
44+
end
45+
46+
def blocked_tag?(node)
47+
BLOCKED_TAGS.include?(node.name.downcase)
48+
end
49+
50+
def clean_attributes(node)
51+
node.attribute_nodes.each { |attr| check_and_clean_attribute(node, attr) }
52+
end
53+
54+
def check_and_clean_attribute(node, attr)
55+
attribute = AttributeValidator.new(attr)
56+
return unless attribute.valid?
57+
58+
remove_if_dangerous(node, attribute)
59+
end
60+
61+
def remove_if_dangerous(node, attribute)
62+
node.remove_attribute(attribute.name) if attribute.dangerous?
63+
end
64+
65+
def format_output(doc)
66+
doc.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::NO_DECLARATION)
67+
end
68+
end
69+
70+
# Encapsulates attribute validation logic
71+
class AttributeValidator
72+
attr_reader :name, :value
73+
74+
def initialize(attr)
75+
@name = attr.name.downcase
76+
@value = attr.value.to_s.strip
77+
end
78+
79+
def valid?
80+
name && value
81+
end
82+
83+
def dangerous?
84+
dangerous_name? || dangerous_value?
85+
end
86+
87+
private
88+
89+
def dangerous_name?
90+
name.start_with?('on') || BLOCKED_ATTRIBUTES.include?(name)
91+
end
92+
93+
def dangerous_value?
94+
dangerous_scheme? || contains_javascript?
95+
end
96+
97+
def dangerous_scheme?
98+
return false unless value =~ /\A([a-z0-9+-]+):/i
99+
100+
BLOCKED_SCHEMES.include?(::Regexp.last_match(1).downcase)
101+
end
102+
103+
def contains_javascript?
104+
value.match?(/\bjavascript\s*:/i)
105+
end
106+
end
107+
end
108+
end

spec/lib/chemotion/sanitizer_spec.rb

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,47 @@
103103
expect(result).to eq(svg_reaction_remapped)
104104
end
105105
end
106+
107+
describe 'scrub_svg with dangerous tags' do
108+
it 'removes <script> tags' do
109+
svg = '<svg><script>alert("xss")</script><circle/></svg>'
110+
sanitized = sanitizer.scrub_svg(svg)
111+
expect(sanitized).not_to include('<script>')
112+
expect(sanitized).to include('<circle')
113+
end
114+
115+
it 'removes <iframe> tags' do
116+
svg = '<svg><iframe src="https://malicious.com"/></svg>'
117+
sanitized = sanitizer.scrub_svg(svg)
118+
expect(sanitized).not_to include('<iframe>')
119+
end
120+
121+
it 'removes <embed> tags' do
122+
svg = '<svg><embed src="evil.swf"/></svg>'
123+
sanitized = sanitizer.scrub_svg(svg)
124+
expect(sanitized).not_to include('<embed>')
125+
end
126+
127+
it 'removes onclick attributes' do
128+
svg = '<svg><circle onclick="alert(1)"/></svg>'
129+
sanitized = sanitizer.scrub_svg(svg)
130+
expect(sanitized).not_to include('onclick')
131+
expect(sanitized).to include('<circle')
132+
end
133+
134+
it 'removes javascript: URLs' do
135+
svg = '<svg><a href="javascript:alert(1)">Click</a></svg>'
136+
sanitized = sanitizer.scrub_svg(svg)
137+
expect(sanitized).not_to include('javascript:')
138+
end
139+
140+
it 'keeps safe SVG elements and attributes' do
141+
svg = '<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="40" fill="red"/></svg>'
142+
sanitized = sanitizer.scrub_svg(svg)
143+
expect(sanitized).to include('<circle')
144+
expect(sanitized).to include('fill="red"')
145+
end
146+
end
106147
end
107148
# rubocop:enable RSpec/MultipleMemoizedHelpers
108149
# rubocop:enable Rspec/IndexedLet

0 commit comments

Comments
 (0)