canvas_link_migrator 1.0.19 → 1.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ca1247f98a0eee8de26f1fa32da2ec683f374ea995a695bb4af201d87644795a
|
|
4
|
+
data.tar.gz: 122790ae2f969eee61c75118096d2e9b736fc328e9a27846f8b5b2e82ec7830b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 37e6e2273e60f1e4428793295e8610e21c138ae16527d54d3a8885c9b5ed88e3660e521e63386ea15f7b7fdab8201f29b0732360e26dce640020c40ec9e88985
|
|
7
|
+
data.tar.gz: 1d7e969234f632637138adcffa681a30a2a0d5fb3391ef080010c2252fbcd2520ed9e726ab4267793d8c1402baa45b5b910a16cce74e421be740899daccc4b7c
|
|
@@ -19,16 +19,38 @@
|
|
|
19
19
|
|
|
20
20
|
module CanvasLinkMigrator
|
|
21
21
|
class LinkReplacer
|
|
22
|
+
HTML_ESCAPES = { '"' => """, "<" => "<", ">" => ">" }.freeze
|
|
23
|
+
HTML_ESCAPE_RE = Regexp.union(HTML_ESCAPES.keys).freeze
|
|
24
|
+
|
|
25
|
+
def self.escape_url_for_html(value)
|
|
26
|
+
value.to_s.gsub(HTML_ESCAPE_RE, HTML_ESCAPES)
|
|
27
|
+
end
|
|
28
|
+
|
|
22
29
|
# returns false if no substitutions were made
|
|
23
30
|
def self.sub_placeholders!(html, links)
|
|
24
31
|
subbed = false
|
|
25
|
-
links.
|
|
32
|
+
media_links, other_links = links.partition { |l| l[:link_type] == :media_object }
|
|
33
|
+
|
|
34
|
+
if media_links.any?
|
|
35
|
+
by_placeholder = media_links.to_h { |l| [l[:placeholder], l] }
|
|
36
|
+
if html.gsub!(Regexp.union(by_placeholder.keys)) { |m|
|
|
37
|
+
link = by_placeholder[m]
|
|
38
|
+
link[:replaced] = true
|
|
39
|
+
link[:new_value] || link[:old_value]
|
|
40
|
+
}
|
|
41
|
+
subbed = true
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
other_links.each do |link|
|
|
26
46
|
new_value = link[:new_value] || link[:old_value]
|
|
27
|
-
|
|
47
|
+
new_value = escape_url_for_html(new_value)
|
|
48
|
+
if html.gsub!(link[:placeholder]) { new_value }
|
|
28
49
|
link[:replaced] = true
|
|
29
50
|
subbed = true
|
|
30
51
|
end
|
|
31
52
|
end
|
|
53
|
+
|
|
32
54
|
subbed
|
|
33
55
|
end
|
|
34
56
|
|
|
@@ -140,6 +140,35 @@ describe CanvasLinkMigrator::ImportedHtmlConverter do
|
|
|
140
140
|
test_string = %(<img src="%24IMS_CC_FILEBASE%24/test.png?notarelevantparam" alt="nope" />)
|
|
141
141
|
expect(@converter.convert_exported_html(test_string)).to eq([%(<img src="#{@path}files/5/preview?verifier=u5" alt="nope">), nil])
|
|
142
142
|
end
|
|
143
|
+
|
|
144
|
+
describe "path-segment injection via canvas_* parameter names" do
|
|
145
|
+
it "does not allow `..` path traversal via canvas_* keys" do
|
|
146
|
+
test_string = %(<img src="%24IMS_CC_FILEBASE%24/test.png?canvas_..%2F..%2Faccounts%2F1%2Fadmin=x" alt="nope" />)
|
|
147
|
+
html, _ = @converter.convert_exported_html(test_string)
|
|
148
|
+
src = Nokogiri::HTML5.fragment(html).at_css("img")["src"]
|
|
149
|
+
expect(src).not_to include(".."),
|
|
150
|
+
"path traversal: src contains `..` segments -- output was: #{src.inspect}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it "does not allow a single `..` segment via canvas_* keys" do
|
|
154
|
+
test_string = %(<a href="%24IMS_CC_FILEBASE%24/test.png?canvas_..=x">x</a>)
|
|
155
|
+
html, _ = @converter.convert_exported_html(test_string)
|
|
156
|
+
href = Nokogiri::HTML5.fragment(html).at_css("a")["href"]
|
|
157
|
+
expect(href).not_to match(%r{/\.\.(?:/|\?|#|\z)}),
|
|
158
|
+
"path traversal: href ends in a `..` segment -- output was: #{href.inspect}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it "does not allow `#` fragment injection that hides the verifier" do
|
|
162
|
+
test_string = %(<a href="%24IMS_CC_FILEBASE%24/test.png?canvas_a%23evil=x">x</a>)
|
|
163
|
+
html, _ = @converter.convert_exported_html(test_string)
|
|
164
|
+
href = Nokogiri::HTML5.fragment(html).at_css("a")["href"]
|
|
165
|
+
parsed = URI.parse(href)
|
|
166
|
+
expect(parsed.fragment).to be_nil,
|
|
167
|
+
"fragment injection: produced fragment #{parsed.fragment.inspect} -- output was: #{href.inspect}"
|
|
168
|
+
expect(parsed.query.to_s).to include("verifier="),
|
|
169
|
+
"verifier was pushed into the fragment by `#` injection -- output was: #{href.inspect}"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
143
172
|
end
|
|
144
173
|
|
|
145
174
|
it "converts picture source srcsets" do
|
|
@@ -190,6 +219,113 @@ describe CanvasLinkMigrator::ImportedHtmlConverter do
|
|
|
190
219
|
expect(@converter.convert_exported_html(test_string)).to eq([%(<a href="/courses/123">Mine</a><br><a href="/courses/456">Vain</a><br><a href="http://other-canvas.example.com/">Other Instance</a>), nil])
|
|
191
220
|
end
|
|
192
221
|
|
|
222
|
+
describe "percent-encoded HTML in unresolved file references (CSAN-045)" do
|
|
223
|
+
it "does not allow attribute breakout via $IMS-CC-FILEBASE$ references" do
|
|
224
|
+
test_string = %(<img src="$IMS-CC-FILEBASE$/foo%22%09onerror=%22alert(1).jpg">)
|
|
225
|
+
html, _bad_links = @converter.convert_exported_html(test_string)
|
|
226
|
+
|
|
227
|
+
img = Nokogiri::HTML5.fragment(html).at_css("img")
|
|
228
|
+
expect(img).not_to be_nil
|
|
229
|
+
expect(img["onerror"]).to be_nil,
|
|
230
|
+
"attribute breakout: produced live onerror -- output was: #{html.inspect}"
|
|
231
|
+
expect(img.attributes.keys).to contain_exactly("src"),
|
|
232
|
+
"expected only `src`, got #{img.attributes.keys.inspect} -- output was: #{html.inspect}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
it "does not allow attribute breakout via plain relative URLs" do
|
|
236
|
+
test_string = %(<a href="relative/foo%22%09onmouseover=%22alert(1).html">x</a>)
|
|
237
|
+
html, _bad_links = @converter.convert_exported_html(test_string)
|
|
238
|
+
|
|
239
|
+
anchor = Nokogiri::HTML5.fragment(html).at_css("a")
|
|
240
|
+
expect(anchor).not_to be_nil
|
|
241
|
+
expect(anchor["onmouseover"]).to be_nil,
|
|
242
|
+
"attribute breakout: produced live onmouseover -- output was: #{html.inspect}"
|
|
243
|
+
expect(anchor.attributes.keys).to contain_exactly("href"),
|
|
244
|
+
"expected only `href`, got #{anchor.attributes.keys.inspect} -- output was: #{html.inspect}"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
it "does not allow attribute breakout via $CANVAS_COURSE_REFERENCE$/file_ref/ rest" do
|
|
248
|
+
test_string = %(<a href="$CANVAS_COURSE_REFERENCE$/file_ref/E/preview" onmouseover="alert(1)">x</a>)
|
|
249
|
+
html, _bad_links = @converter.convert_exported_html(test_string)
|
|
250
|
+
|
|
251
|
+
anchor = Nokogiri::HTML5.fragment(html).at_css("a")
|
|
252
|
+
expect(anchor).not_to be_nil
|
|
253
|
+
expect(anchor["onmouseover"]).to be_nil,
|
|
254
|
+
"attribute breakout via file_ref rest -- output was: #{html.inspect}"
|
|
255
|
+
expect(anchor.attributes.keys).to contain_exactly("href"),
|
|
256
|
+
"expected only `href`, got #{anchor.attributes.keys.inspect} -- output was: #{html.inspect}"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
it "does not allow attribute breakout via $CANVAS_COURSE_REFERENCE$/modules/items/ query" do
|
|
260
|
+
test_string = %(<a href="$CANVAS_COURSE_REFERENCE$/modules/items/C?foo=bar" onmouseover="alert(1)">x</a>)
|
|
261
|
+
html, _bad_links = @converter.convert_exported_html(test_string)
|
|
262
|
+
|
|
263
|
+
anchor = Nokogiri::HTML5.fragment(html).at_css("a")
|
|
264
|
+
expect(anchor).not_to be_nil
|
|
265
|
+
expect(anchor["onmouseover"]).to be_nil,
|
|
266
|
+
"attribute breakout via module_item query -- output was: #{html.inspect}"
|
|
267
|
+
expect(anchor.attributes.keys).to contain_exactly("href"),
|
|
268
|
+
"expected only `href`, got #{anchor.attributes.keys.inspect} -- output was: #{html.inspect}"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
it "does not allow attribute breakout via wiki_page_migration_id fallback" do
|
|
272
|
+
test_string = %(<a href="wiki_page_migration_id=nope" onmouseover="alert(1)">x</a>)
|
|
273
|
+
html, _bad_links = @converter.convert_exported_html(test_string)
|
|
274
|
+
|
|
275
|
+
anchor = Nokogiri::HTML5.fragment(html).at_css("a")
|
|
276
|
+
expect(anchor).not_to be_nil
|
|
277
|
+
expect(anchor["onmouseover"]).to be_nil,
|
|
278
|
+
"attribute breakout via wiki_page fallback -- output was: #{html.inspect}"
|
|
279
|
+
expect(anchor.attributes.keys).to contain_exactly("href"),
|
|
280
|
+
"expected only `href`, got #{anchor.attributes.keys.inspect} -- output was: #{html.inspect}"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
it "does not allow text-node injection when placeholder lives in inner_html" do
|
|
284
|
+
payload = "relative/foo%3Cimg%09src=x%09onerror=alert(1)%3E.html"
|
|
285
|
+
test_string = %(<a href="#{payload}">#{payload}</a>)
|
|
286
|
+
html, _bad_links = @converter.convert_exported_html(test_string)
|
|
287
|
+
|
|
288
|
+
anchor = Nokogiri::HTML5.fragment(html).at_css("a")
|
|
289
|
+
expect(anchor).not_to be_nil
|
|
290
|
+
expect(anchor.css("img")).to be_empty,
|
|
291
|
+
"text-node injection: produced live <img> child -- output was: #{html.inspect}"
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
it "does not allow attribute injection via gsub! pre-match back-reference" do
|
|
295
|
+
test_string = %q(<img src="wiki_page_migration_id=NONEXIST\`onerror=alert(1) ">)
|
|
296
|
+
html, _bad_links = @converter.convert_exported_html(test_string)
|
|
297
|
+
|
|
298
|
+
img = Nokogiri::HTML5.fragment(html).at_css("img")
|
|
299
|
+
expect(img).not_to be_nil
|
|
300
|
+
expect(img["onerror"]).to be_nil,
|
|
301
|
+
"attribute injection via gsub backref: produced live onerror -- output was: #{html.inspect}"
|
|
302
|
+
expect(img.attributes.keys).to contain_exactly("src"),
|
|
303
|
+
"expected only `src`, got #{img.attributes.keys.inspect} -- output was: #{html.inspect}"
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
it "does not allow media_object cross-substitution into attribute context" do
|
|
307
|
+
attacker_video = %q(<video data-media-id="m-stuff" data-media-type="video"><source src="$IMS-CC-FILEBASE$/test.png" data-media-id="m-stuff" data-media-type="video"></video>)
|
|
308
|
+
preview = Nokogiri::HTML5.fragment(attacker_video, max_tree_depth: 10_000)
|
|
309
|
+
preview.search("source[data-media-type],source[data-media-id]").each do |source|
|
|
310
|
+
next unless %w[audio video].include?(source.parent.name)
|
|
311
|
+
media_node = source.parent
|
|
312
|
+
media_node.name = "iframe"
|
|
313
|
+
media_node["src"] = source["src"]
|
|
314
|
+
source.remove
|
|
315
|
+
end
|
|
316
|
+
predicted_placeholder = "LINK.PLACEHOLDER_#{Digest::MD5.hexdigest(preview.at_css("iframe").to_xml)}"
|
|
317
|
+
|
|
318
|
+
attack_html = %Q(<a href="evil/#{predicted_placeholder}">link</a>#{attacker_video})
|
|
319
|
+
html, _ = @converter.convert_exported_html(attack_html)
|
|
320
|
+
|
|
321
|
+
anchor = Nokogiri::HTML5.fragment(html).at_css("a")
|
|
322
|
+
expect(anchor).not_to be_nil
|
|
323
|
+
expect(anchor.attributes.keys).to contain_exactly("href"),
|
|
324
|
+
"media_object cross-substitution injected attributes onto anchor: " \
|
|
325
|
+
"#{anchor.attributes.keys.inspect} -- output was: #{html.inspect}"
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
193
329
|
it "prepends course files for unrecognized relative urls" do
|
|
194
330
|
test_string = %(<a href="/relative/path/to/file">Linkage</a>)
|
|
195
331
|
html, bad_links = @converter.convert_exported_html(test_string)
|
metadata
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: canvas_link_migrator
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0.
|
|
4
|
+
version: 1.0.20
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mysti Lilla
|
|
8
8
|
- James Logan
|
|
9
9
|
- Sarah Gerard
|
|
10
10
|
- Math Costa
|
|
11
|
-
autorequire:
|
|
11
|
+
autorequire:
|
|
12
12
|
bindir: bin
|
|
13
13
|
cert_chain: []
|
|
14
|
-
date: 2026-
|
|
14
|
+
date: 2026-05-15 00:00:00.000000000 Z
|
|
15
15
|
dependencies:
|
|
16
16
|
- !ruby/object:Gem::Dependency
|
|
17
17
|
name: activesupport
|
|
@@ -139,7 +139,7 @@ dependencies:
|
|
|
139
139
|
- - ">="
|
|
140
140
|
- !ruby/object:Gem::Version
|
|
141
141
|
version: '0'
|
|
142
|
-
description:
|
|
142
|
+
description:
|
|
143
143
|
email:
|
|
144
144
|
- mysti@instructure.com
|
|
145
145
|
- james.logan@instructure.com
|
|
@@ -164,11 +164,11 @@ files:
|
|
|
164
164
|
- spec/fixtures/canvas_resource_map_pages.json
|
|
165
165
|
- spec/spec_helper.rb
|
|
166
166
|
- test.sh
|
|
167
|
-
homepage:
|
|
167
|
+
homepage:
|
|
168
168
|
licenses: []
|
|
169
169
|
metadata:
|
|
170
170
|
source_code_uri: https://github.com/instructure/canvas_link_migrator
|
|
171
|
-
post_install_message:
|
|
171
|
+
post_install_message:
|
|
172
172
|
rdoc_options: []
|
|
173
173
|
require_paths:
|
|
174
174
|
- lib
|
|
@@ -176,15 +176,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
176
176
|
requirements:
|
|
177
177
|
- - ">="
|
|
178
178
|
- !ruby/object:Gem::Version
|
|
179
|
-
version: '
|
|
179
|
+
version: '3.1'
|
|
180
180
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
181
181
|
requirements:
|
|
182
182
|
- - ">="
|
|
183
183
|
- !ruby/object:Gem::Version
|
|
184
184
|
version: '0'
|
|
185
185
|
requirements: []
|
|
186
|
-
rubygems_version: 3.
|
|
187
|
-
signing_key:
|
|
186
|
+
rubygems_version: 3.3.27
|
|
187
|
+
signing_key:
|
|
188
188
|
specification_version: 4
|
|
189
189
|
summary: Instructure gem for migrating Canvas style rich content
|
|
190
190
|
test_files: []
|