amend CSP header to allow LTIs embedded in LTIs
This lets the retrieve and resource_selection endpoints embed inside an iframe inside of New Quizzes. closes INTEROP-7752 closes INTEROP-7742 Test plan: - if you have New Quizzes set up, and they are embedding tools from within their tool, you might be able to test with that. Otherwise, you can test with LTI 1.3 test tool as mentioned below. - modify the LTI 1.3 test tool dev key to have internal_service=true - in the LTI 1.3 test tool's app/views/launch/launch.html.erb add an iframe pointing to resource_selection like this: <iframe src="http://web.canvas-lms.docker/courses/66/external_tools/181/resource_selection?parent_frame_context=155&editor=1" style="width: 600px; height: 400px" ></iframe> The parent_frame_context parameter should be your LTI 1.3 test tool ID. The first tool ID can be any LTI 1.1 tool (LTI 1.3 currently needs an additional change -- to the LTI 1.3 'authorize' endpoint -- to work) - Launch the LTI 1.3 tool. Your LTI 1.1 tool should successfully launch - Change the iframe you added above to a retrieve URL, e.g.: http://web.canvas-lms.docker/courses/66/external_tools/retrieve?url=http%3A%2F%2Fmylti1tool.example.com%2F&parent_frame_context=155" - Where the URL is a URL for an LTI 1.1 tool available in the course, and 155 is your LTI 1.3 test tool ID Change-Id: Ie27c030bbd95b85af60e391c14bf6c52aad087be Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/306023 Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com> Reviewed-by: Paul Gray <paul.gray@instructure.com> QA-Review: Paul Gray <paul.gray@instructure.com> Product-Review: Evan Battaglia <ebattaglia@instructure.com>
This commit is contained in:
parent
e06724f8d1
commit
f58237d341
|
@ -799,20 +799,19 @@ class ApplicationController < ActionController::Base
|
|||
require_user
|
||||
end
|
||||
|
||||
def csp_frame_ancestors
|
||||
# Allow iframing on all vanity domains as well as the canonical one
|
||||
unless @domain_root_account.nil?
|
||||
HostUrl.context_hosts(@domain_root_account, request.host)
|
||||
end
|
||||
end
|
||||
|
||||
def set_response_headers
|
||||
# we can't block frames on the files domain, since files domain requests
|
||||
# are typically embedded in an iframe in canvas, but the hostname is
|
||||
# different
|
||||
if !files_domain? && Setting.get("block_html_frames", "true") == "true" && !@embeddable
|
||||
#
|
||||
# Allow iframing on all vanity domains as well as the canonical one
|
||||
#
|
||||
equivalent_domains = []
|
||||
unless @domain_root_account.nil?
|
||||
equivalent_domains = HostUrl.context_hosts(@domain_root_account, request.host)
|
||||
end
|
||||
|
||||
append_to_header("Content-Security-Policy", "frame-ancestors 'self' #{equivalent_domains.join(" ")};")
|
||||
append_to_header("Content-Security-Policy", "frame-ancestors 'self' #{csp_frame_ancestors&.join(" ")};")
|
||||
end
|
||||
headers["Strict-Transport-Security"] = "max-age=31536000" if request.ssl?
|
||||
RequestContext::Generator.store_request_meta(request, @context, @sentry_trace)
|
||||
|
|
|
@ -88,11 +88,8 @@ class ExternalContentController < ApplicationController
|
|||
error_message: param_if_set(:lti_errormsg),
|
||||
error_log: param_if_set(:lti_errorlog)
|
||||
})
|
||||
tool_origin = parent_frame_origin(params[:parent_frame_context])
|
||||
if tool_origin
|
||||
js_env({
|
||||
DEEP_LINKING_POST_MESSAGE_ORIGIN: tool_origin
|
||||
}, true)
|
||||
if parent_frame_origin
|
||||
js_env({ DEEP_LINKING_POST_MESSAGE_ORIGIN: parent_frame_origin }, true)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -33,11 +33,13 @@ class ExternalToolsController < ApplicationController
|
|||
before_action :require_user, only: [:generate_sessionless_launch]
|
||||
before_action :get_context, only: %i[retrieve show resource_selection]
|
||||
before_action :parse_context_codes, only: [:all_visible_nav_tools]
|
||||
before_action :set_extra_csp_frame_ancestor!, only: %i[retrieve resource_selection]
|
||||
skip_before_action :verify_authenticity_token, only: :resource_selection
|
||||
|
||||
include Api::V1::ExternalTools
|
||||
include Lti::RedisMessageClient
|
||||
include Lti::Concerns::SessionlessLaunches
|
||||
include Lti::Concerns::ParentFrame
|
||||
include K5Mode
|
||||
|
||||
WHITELISTED_QUERY_PARAMS = [
|
||||
|
|
|
@ -17,21 +17,44 @@
|
|||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Methods for controller actions which take a parent_frame_context param to
|
||||
# facilitate embedding inside trusted tools (e.g. New Quizzes). Depending on
|
||||
# the action then may need to one or both of these things:
|
||||
# 1. Set the origin to be used with postMessage to allow the embedded tool to
|
||||
# send messages to the embedding (trusted) tool (usually set in js_env to
|
||||
# the value of parent_frame_origin)
|
||||
# 2. Add the embedding tool's host to the Content-Security-Policy (CSP)
|
||||
# header's frame-ancestor directive to allow the page to me loaded in an
|
||||
# iframe embedded in the embedding tool. For this, call
|
||||
# set_extra_csp_frame_ancestor!
|
||||
module Lti::Concerns
|
||||
module ParentFrame
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Takes an id of a tool and returns the origin of that tool if it exists
|
||||
# otherwise returns nil
|
||||
def parent_frame_origin(tool_id)
|
||||
tool = tool_id ? ContextExternalTool.find_by(id: tool_id) : nil
|
||||
return nil unless tool&.active? && tool&.developer_key&.internal_service
|
||||
private
|
||||
|
||||
if tool.url
|
||||
override_parent_frame_origin(tool.url)
|
||||
elsif tool.domain
|
||||
"https://#{tool.domain}"
|
||||
end
|
||||
# Can be overridden by controller if the parent_frame_context (tool ID)
|
||||
# comes from someplace else (as it does for DeepLinkingController)
|
||||
def parent_frame_context
|
||||
params[:parent_frame_context]
|
||||
end
|
||||
|
||||
# Finds the tool with id parent_frame_context and returns the origin
|
||||
# (host/scheme/port based off of URL/domain) of that tool if it exists.
|
||||
# otherwise returns nil. Memoized.
|
||||
def parent_frame_origin
|
||||
return @parent_frame_origin if defined?(@parent_frame_origin)
|
||||
|
||||
tool = parent_frame_context.presence && ContextExternalTool.find_by(id: parent_frame_context)
|
||||
|
||||
@parent_frame_origin =
|
||||
if !tool&.active? || !tool&.developer_key&.internal_service
|
||||
nil
|
||||
elsif tool.url
|
||||
override_parent_frame_origin(tool.url)
|
||||
elsif tool.domain
|
||||
"https://#{tool.domain}"
|
||||
end
|
||||
end
|
||||
|
||||
def override_parent_frame_origin(url)
|
||||
|
@ -39,5 +62,18 @@ module Lti::Concerns
|
|||
origin = URI("#{uri.scheme}://#{uri.host}:#{uri.port}")
|
||||
origin.to_s
|
||||
end
|
||||
|
||||
# Overrides the method in ApplicationController, and is used there
|
||||
def csp_frame_ancestors
|
||||
[*super, @extra_csp_frame_ancestor].compact
|
||||
end
|
||||
|
||||
def set_extra_csp_frame_ancestor!
|
||||
# require http/https URI (e.g., no 'data' uris') & don't allow potential
|
||||
# specially characters that could mess up header
|
||||
if parent_frame_origin&.match(/^https?:[^ *;]+$/)
|
||||
@extra_csp_frame_ancestor = parent_frame_origin
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -134,6 +134,11 @@ module Lti
|
|||
raise e
|
||||
end
|
||||
|
||||
# Overrides method in Lti::Concerns::ParentFrame
|
||||
def parent_frame_context
|
||||
return_url_parameters[:parent_frame_context]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def for_placement?(placement)
|
||||
|
@ -151,14 +156,11 @@ module Lti
|
|||
errorlog: messaging_value("errorlog"),
|
||||
ltiEndpoint: polymorphic_url([:retrieve, @context, :external_tools]),
|
||||
reloadpage: reload_page,
|
||||
moduleCreated: module_created
|
||||
moduleCreated: module_created,
|
||||
}.compact
|
||||
})
|
||||
tool_origin = parent_frame_origin(return_url_parameters[:parent_frame_context])
|
||||
if tool_origin
|
||||
js_env({
|
||||
DEEP_LINKING_POST_MESSAGE_ORIGIN: tool_origin
|
||||
}, true)
|
||||
if parent_frame_origin
|
||||
js_env({ DEEP_LINKING_POST_MESSAGE_ORIGIN: parent_frame_origin }, true)
|
||||
end
|
||||
|
||||
render layout: "bare"
|
||||
|
|
|
@ -697,6 +697,68 @@ describe ExternalToolsController do
|
|||
end
|
||||
end
|
||||
|
||||
shared_examples_for "an endpoint which uses parent_frame_context to set the CSP header" do
|
||||
before do
|
||||
user_session(@student)
|
||||
end
|
||||
|
||||
let(:pfc_tool) do
|
||||
@course.context_external_tools.create(
|
||||
name: "instructure_tool", consumer_key: "foo", shared_secret: "bar",
|
||||
url: "http://inst-tool.example.com/abc", lti_version: "1.1",
|
||||
developer_key: DeveloperKey.new(
|
||||
root_account: @course.root_account,
|
||||
internal_service: true
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
let(:csp_header) { response.headers["Content-Security-Policy"] }
|
||||
|
||||
it "adds the tool URL to the header if the parent_frame_context tool is trusted" do
|
||||
subject
|
||||
expect(response).to be_successful
|
||||
expect(csp_header).to match %r{frame-ancestors [^;]*http://inst-tool.example.com(;| |$)}
|
||||
# Make sure it also has 'self', which is added in application_controller.rb:
|
||||
expect(csp_header).to match(/frame-ancestors [^;]*'self'/)
|
||||
end
|
||||
|
||||
it "doesn't add the URL to the header if the parent_frame_context tool is not trusted" do
|
||||
pfc_tool.developer_key.update! internal_service: false
|
||||
subject
|
||||
expect(response).to be_successful
|
||||
expect(csp_header).to_not include("inst-tool.example.com")
|
||||
end
|
||||
|
||||
it "doesn't add the URL to the header if the parent_frame_context tool is not active" do
|
||||
pfc_tool.update! workflow_state: :deleted
|
||||
subject
|
||||
expect(response).to be_successful
|
||||
expect(csp_header).to_not include("inst-tool.example.com")
|
||||
end
|
||||
|
||||
it "doesn't add the URL to the header if the parent_frame_context tool's URL has unsafe characters" do
|
||||
pfc_tool.update_attribute :url, "http://inst-tool.example.com;default-src"
|
||||
subject
|
||||
expect(response).to be_successful
|
||||
expect(csp_header).to_not include("inst-tool.example.com")
|
||||
end
|
||||
|
||||
it "doesn't add the parent_frame_context tool's URL to the header if it is a data URL" do
|
||||
pfc_tool.update! url: "data:123"
|
||||
subject
|
||||
expect(response).to be_successful
|
||||
expect(csp_header).to_not include("data:abc")
|
||||
end
|
||||
|
||||
it "doesn't add the parent_frame_context tool's URL to the header if the user is not authenticated" do
|
||||
user_session(user_model)
|
||||
subject
|
||||
expect(response.status).to eq(401)
|
||||
expect(csp_header.to_s).to_not include("inst-tool.example.com")
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET 'retrieve'" do
|
||||
let :account do
|
||||
Account.default
|
||||
|
@ -1019,6 +1081,15 @@ describe ExternalToolsController do
|
|||
expect(assigns[:lti_launch].params["ext_lti_assignment_id"]).to eq lti_assignment_id
|
||||
end
|
||||
|
||||
it_behaves_like "an endpoint which uses parent_frame_context to set the CSP header" do
|
||||
subject do
|
||||
get :retrieve, params: {
|
||||
url: tool.url, course_id: @course.id,
|
||||
parent_frame_context: pfc_tool.id
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
context "for Quizzes Next launch" do
|
||||
let(:assignment) do
|
||||
a = assignment_model(course: @course)
|
||||
|
@ -1331,6 +1402,16 @@ describe ExternalToolsController do
|
|||
expect(assigns[:lti_launch].params["custom_canvas_enrollment_state"]).to eq "inactive"
|
||||
end
|
||||
|
||||
it_behaves_like "an endpoint which uses parent_frame_context to set the CSP header" do
|
||||
subject do
|
||||
tool = new_valid_tool(@course)
|
||||
get "resource_selection", params: {
|
||||
course_id: @course.id, external_tool_id: tool.id,
|
||||
parent_frame_context: pfc_tool.id
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
context "with RCE parameters" do
|
||||
subject do
|
||||
user_session(@student)
|
||||
|
|
|
@ -72,14 +72,14 @@ module Lti
|
|||
subject
|
||||
end
|
||||
|
||||
# TODO: update all these with hash_including()
|
||||
context "when returning from a non-internal service" do
|
||||
let(:return_url_params) { { placement: placement, parent_frame_context: context_external_tool.id } }
|
||||
|
||||
it "does not set the DEEP_LINKING_POST_MESSAGE_ORIGIN value in jsenv" do
|
||||
expect(controller).to receive(:js_env).with(anything)
|
||||
expect(controller).not_to receive(:js_env).with({ DEEP_LINKING_POST_MESSAGE_ORIGIN: "http://tool.url" }, true)
|
||||
|
||||
it "does not change the DEEP_LINKING_POST_MESSAGE_ORIGIN value in jsenv" do
|
||||
subject
|
||||
# base_url is the default
|
||||
expect(assigns(:js_env)[:DEEP_LINKING_POST_MESSAGE_ORIGIN]).to eq(@controller.request.base_url)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -90,11 +90,9 @@ module Lti
|
|||
|
||||
let(:return_url_params) { { placement: placement, parent_frame_context: context_external_tool.id } }
|
||||
|
||||
it "sets the DEEP_LINKING_POST_MESSAGE_ORIGIN value in jsenv" do
|
||||
expect(controller).to receive(:js_env).with(anything)
|
||||
expect(controller).to receive(:js_env).with({ DEEP_LINKING_POST_MESSAGE_ORIGIN: "http://tool.url" }, true)
|
||||
|
||||
it "sets the DEEP_LINKING_POST_MESSAGE_ORIGIN value in js_env" do
|
||||
subject
|
||||
expect(assigns(:js_env)[:DEEP_LINKING_POST_MESSAGE_ORIGIN]).to eq("http://tool.url")
|
||||
end
|
||||
end
|
||||
|
||||
|
|
Loading…
Reference in New Issue