Add support for signed URLs used by the render_file function

This commit is contained in:
Sebastian Serth
2022-09-23 11:26:56 +02:00
parent 5881795d5f
commit 16c00ec136
16 changed files with 229 additions and 31 deletions

View File

@ -19,6 +19,7 @@ gem 'ims-lti', '< 2.0.0'
gem 'jbuilder' gem 'jbuilder'
gem 'json_schemer' gem 'json_schemer'
gem 'js-routes' gem 'js-routes'
gem 'jwt'
gem 'kramdown' gem 'kramdown'
gem 'mimemagic' gem 'mimemagic'
gem 'net-http-persistent' gem 'net-http-persistent'

View File

@ -571,6 +571,7 @@ DEPENDENCIES
jbuilder jbuilder
js-routes js-routes
json_schemer json_schemer
jwt
kramdown kramdown
letter_opener letter_opener
listen listen

View File

@ -134,7 +134,11 @@ CodeOceanEditorSubmissions = {
event.preventDefault(); event.preventDefault();
if ($('#render').is(':visible')) { if ($('#render').is(':visible')) {
this.createSubmission('#render', null, function (response) { this.createSubmission('#render', null, function (response) {
var url = response.render_url.replace(this.FILENAME_URL_PLACEHOLDER, CodeOceanEditor.active_file.filename.replace(/#$/,'')); // remove # if it is the last character, this is not part of the filename and just an anchor if (response.render_url === undefined) return;
const active_file = CodeOceanEditor.active_file.filename.replace(/#$/,''); // remove # if it is the last character, this is not part of the filename and just an anchor
const desired_file = response.render_url.filter(hash => hash.filepath === active_file);
const url = desired_file[0].url;
var pop_up_window = window.open(url); var pop_up_window = window.open(url);
if (pop_up_window) { if (pop_up_window) {
pop_up_window.onerror = function (message) { pop_up_window.onerror = function (message) {

View File

@ -7,6 +7,7 @@ class ApplicationController < ActionController::Base
MEMBER_ACTIONS = %i[destroy edit show update].freeze MEMBER_ACTIONS = %i[destroy edit show update].freeze
RENDER_HOST = CodeOcean::Config.new(:code_ocean).read[:render_host] RENDER_HOST = CodeOcean::Config.new(:code_ocean).read[:render_host]
before_action :deny_access_from_render_host
after_action :verify_authorized, except: %i[welcome] after_action :verify_authorized, except: %i[welcome]
around_action :mnemosyne_trace around_action :mnemosyne_trace
around_action :switch_locale around_action :switch_locale
@ -68,7 +69,7 @@ class ApplicationController < ActionController::Base
id: current_user.id, id: current_user.id,
type: current_user.class.name, type: current_user.class.name,
username: current_user.displayname, username: current_user.displayname,
consumer: current_user.consumer.name consumer: current_user.consumer&.name
) )
end end
private :set_sentry_context private :set_sentry_context
@ -95,10 +96,13 @@ class ApplicationController < ActionController::Base
def render_error(message, status) def render_error(message, status)
set_sentry_context set_sentry_context
respond_to do |format| respond_to do |format|
format.html do format.any do
# Prevent redirect loop # Prevent redirect loop
if request.url == request.referer if request.url == request.referer
redirect_to :root, alert: message redirect_to :root, alert: message
# Redirect to main domain if the request originated from our render_host
elsif request.path == '/' && request.host == RENDER_HOST
redirect_to Rails.application.config.action_mailer.default_url_options
else else
redirect_back fallback_location: :root, allow_other_host: false, alert: message redirect_back fallback_location: :root, allow_other_host: false, alert: message
end end
@ -116,6 +120,10 @@ class ApplicationController < ActionController::Base
end end
private :switch_locale private :switch_locale
def deny_access_from_render_host
raise Pundit::NotAuthorizedError if RENDER_HOST.present? && request.host == RENDER_HOST
end
def welcome def welcome
# Show root page # Show root page
end end

View File

@ -5,6 +5,12 @@ module CodeOcean
include CommonBehavior include CommonBehavior
include FileParameters include FileParameters
# Overwrite the CSP header and some default actions for the :render_protected_upload action
content_security_policy false, only: :render_protected_upload
skip_before_action :deny_access_from_render_host, only: :render_protected_upload
skip_before_action :verify_authenticity_token, only: :render_protected_upload
before_action :require_user!, except: :render_protected_upload
def authorize! def authorize!
authorize(@file) authorize(@file)
end end
@ -19,6 +25,18 @@ module CodeOcean
send_file(real_location, type: @file.native_file.content_type, filename: @file.name_with_extension, disposition: 'attachment') send_file(real_location, type: @file.native_file.content_type, filename: @file.name_with_extension, disposition: 'attachment')
end end
def render_protected_upload
# Set @current_user with a new *learner* for Pundit checks
@current_user = ExternalUser.new
@file = authorize AuthenticatedUrlHelper.retrieve!(CodeOcean::File, request)
raise Pundit::NotAuthorizedError unless @file.name_with_extension == params[:filename]
real_location = Pathname(@file.native_file.current_path).realpath
send_file(real_location, type: @file.native_file.content_type, filename: @file.name_with_extension)
end
def create def create
@file = CodeOcean::File.new(file_params) @file = CodeOcean::File.new(file_params)
if @file.file_template_id if @file.file_template_id

View File

@ -7,22 +7,17 @@ class SubmissionsController < ApplicationController
include SubmissionParameters include SubmissionParameters
include Tubesock::Hijack include Tubesock::Hijack
before_action :require_user! before_action :set_submission, only: %i[download download_file run score show statistics test]
before_action :set_submission, only: %i[download download_file render_file run score show statistics test]
before_action :set_testrun, only: %i[run score test] before_action :set_testrun, only: %i[run score test]
before_action :set_files, only: %i[download show] before_action :set_files, only: %i[download show]
before_action :set_files_and_specific_file, only: %i[download_file render_file run test] before_action :set_files_and_specific_file, only: %i[download_file run test]
before_action :set_content_type_nosniff, only: %i[download download_file render_file] before_action :set_content_type_nosniff, only: %i[download download_file render_file]
# Overwrite the CSP header for the :render_file action # Overwrite the CSP header and some default actions for the :render_file action
content_security_policy only: :render_file do |policy| content_security_policy false, only: :render_file
policy.img_src :none skip_before_action :deny_access_from_render_host, only: :render_file
policy.script_src :none skip_before_action :verify_authenticity_token, only: :render_file
policy.font_src :none before_action :require_user!, except: :render_file
policy.style_src :none
policy.connect_src :none
policy.form_action :none
end
def create def create
@submission = Submission.new(submission_params) @submission = Submission.new(submission_params)
@ -80,10 +75,28 @@ class SubmissionsController < ApplicationController
end end
def render_file def render_file
# If a file should not be downloaded, it should not be rendered either # Set @current_user with a new *learner* for Pundit checks
raise Pundit::NotAuthorizedError if @embed_options[:disable_download] @current_user = ExternalUser.new
send_data(@file.read, filename: @file.name_with_extension, disposition: 'inline') @submission = authorize AuthenticatedUrlHelper.retrieve!(Submission, request, cookies)
# Throws an exception if the file is not found
set_files_and_specific_file
# Allows access to other files of the same submission, e.g., a linked JS or CSS file where we cannot expect a token in the URL
cookie_name = AuthenticatedUrlHelper.cookie_name_for(:render_file_token)
if params[AuthenticatedUrlHelper.query_parameter].present?
cookies[cookie_name] = AuthenticatedUrlHelper.prepare_short_living_cookie(request.url)
cookies.commit!
end
# Finally grant access and send the file
if @file.native_file?
url = render_protected_upload_url(id: @file.id, filename: @file.name_with_extension)
redirect_to AuthenticatedUrlHelper.sign(url, @file)
else
send_data(@file.content, filename: @file.name_with_extension, disposition: 'inline')
end
end end
# rubocop:disable Metrics/CyclomaticComplexity # rubocop:disable Metrics/CyclomaticComplexity
@ -372,7 +385,7 @@ class SubmissionsController < ApplicationController
# @file contains the specific file requested for run / test / render / ... # @file contains the specific file requested for run / test / render / ...
set_files set_files
@file = @files.detect {|file| file.filepath == sanitize_filename } @file = @files.detect {|file| file.filepath == sanitize_filename }
head :not_found unless @file raise ActiveRecord::RecordNotFound unless @file
end end
def set_files def set_files

View File

@ -0,0 +1,111 @@
# frozen_string_literal: true
module AuthenticatedUrlHelper
include Pundit::Authorization
class << self
TOKEN_ALGORITHM = 'HS512'
TOKEN_EXPIRATION = 10.minutes
TOKEN_SECRET = Rails.application.secrets.secret_key_base
TOKEN_PARAM = :token
COOKIE_EXPIRATION = 30.seconds
def sign(url, object)
payload = {object_id: object.id, object_type: object.class.name, url: url, exp: TOKEN_EXPIRATION.from_now.to_i}
token = JWT.encode payload, TOKEN_SECRET, TOKEN_ALGORITHM
add_query_parameters(url, {TOKEN_PARAM => token})
end
def retrieve!(klass, request, cookies = {})
# Don't use the default session mechanism and default cookie
request.session_options[:skip] = true
# Show errors as JSON format, if any
request.format = :json
# Disallow access from normal domain and show an error instead
if ApplicationController::RENDER_HOST.present? && request.host != ApplicationController::RENDER_HOST
raise Pundit::NotAuthorizedError
end
cookie_name = AuthenticatedUrlHelper.cookie_name_for(:render_file_token)
object = klass.find(request.parameters[:id])
signed_url = request.parameters[TOKEN_PARAM].present? ? request.url : cookies[cookie_name]
# Throws an exception if the token is not matching the object or has expired
AuthenticatedUrlHelper.verify!(signed_url, object, klass)
object
end
def verify!(url, object, klass)
original_url, removed_parameters = remove_query_parameters(url, [TOKEN_PARAM])
expected_payload = {object_id: object.id, object_type: klass.name, url: original_url}
token = removed_parameters[TOKEN_PARAM]
begin
payload, = JWT.decode token, TOKEN_SECRET, true, algorithm: TOKEN_ALGORITHM
rescue JWT::DecodeError
raise Pundit::NotAuthorizedError
end
raise Pundit::NotAuthorizedError unless payload.symbolize_keys.except(:exp) == expected_payload
end
def prepare_short_living_cookie(value)
{
value: value,
expires: COOKIE_EXPIRATION.from_now,
httponly: true,
same_site: :strict,
secure: Rails.env.production? || Rails.env.staging?,
path: Rails.application.config.relative_url_root,
}
end
def cookie_name_for(name)
if (Rails.env.production? || Rails.env.staging?) \
&& Rails.application.config.relative_url_root == '/'
"__Host-#{name}"
elsif Rails.env.production? || Rails.env.staging?
"__Secure-#{name}"
else
name
end
end
def query_parameter
TOKEN_PARAM
end
private
def add_query_parameters(url, parameters)
parsed_url = URI.parse url
# Add the given parameters to the query string
query_params = CGI.parse(parsed_url.query || '')
query_params.merge!(parameters)
# Add the query string back to the URL
parsed_url.query = URI.encode_www_form(query_params)
# Return the full URL
parsed_url.to_s
end
def remove_query_parameters(url, parameters)
parsed_url = URI.parse url
# Remove the given parameters from the query string
query_params = Rack::Utils.parse_nested_query(parsed_url.query || '')
removed_params = query_params.slice!(parameters)
# Add the query string back to the URL
parsed_url.query = URI.encode_www_form(query_params).presence
# Return the full URL and removed parameters
[parsed_url.to_s, removed_params.symbolize_keys]
end
end
end

View File

@ -26,6 +26,14 @@ module CodeOcean
end end
end end
def render_protected_upload?
return no_one if @record.native_file? && !@record.native_file_location_valid?
return no_one if @record.context.is_a?(Exercise) && (@record.context.unpublished || @record.hidden)
# The AuthenticatedUrlHelper will check for more details, but we cannot determine a specific user
everyone
end
def create? def create?
if @record.context.is_a?(Exercise) if @record.context.is_a?(Exercise)
admin? || author? admin? || author?

View File

@ -6,11 +6,15 @@ class SubmissionPolicy < ApplicationPolicy
end end
# insights? is used in the flowr_controller.rb as we use it to authorize the user for a submission # insights? is used in the flowr_controller.rb as we use it to authorize the user for a submission
%i[download? download_file? render_file? run? score? show? statistics? stop? test? %i[download? download_file? run? score? show? statistics? stop? test?
insights?].each do |action| insights?].each do |action|
define_method(action) { admin? || author? } define_method(action) { admin? || author? }
end end
def render_file?
everyone
end
def index? def index?
admin? admin?
end end

View File

@ -5,6 +5,14 @@ json.download_url download_submission_path(@submission, format: :json)
json.score_url score_submission_path(@submission, format: :json) json.score_url score_submission_path(@submission, format: :json)
json.download_file_url download_file_submission_path(@submission, 'a.', format: :json).gsub(/a\.\.json$/, json.download_file_url download_file_submission_path(@submission, 'a.', format: :json).gsub(/a\.\.json$/,
'{filename}.json') '{filename}.json')
json.render_url render_submission_path(@submission, 'a.', format: :json).gsub(/a\.\.json$/, '{filename}.json') unless @embed_options[:disable_download]
json.render_url @submission.collect_files.select(&:visible) do |files|
host = ApplicationController::RENDER_HOST || request.host
url = render_submission_url(@submission, files.filepath, host: host)
json.filepath files.filepath
json.url AuthenticatedUrlHelper.sign(url, @submission)
end
end
json.run_url run_submission_path(@submission, 'a.', format: :json).gsub(/a\.\.json$/, '{filename}.json') json.run_url run_submission_path(@submission, 'a.', format: :json).gsub(/a\.\.json$/, '{filename}.json')
json.test_url test_submission_path(@submission, 'a.', format: :json).gsub(/a\.\.json$/, '{filename}.json') json.test_url test_submission_path(@submission, 'a.', format: :json).gsub(/a\.\.json$/, '{filename}.json')

View File

@ -3,5 +3,4 @@ test:
from: codeocean@hpi.de from: codeocean@hpi.de
default_url_options: default_url_options:
host: localhost host: localhost
port: 3000
delivery_method: :test delivery_method: :test

View File

@ -19,5 +19,4 @@ test:
<<: *default <<: *default
default_url_options: default_url_options:
host: localhost host: localhost
port: 3000
delivery_method: :test delivery_method: :test

View File

@ -1,4 +1,8 @@
default: &default default: &default
# A public-facing host to be used for the render_file function of the SubmissionsController.
# User content will be served from this host. If not set, the default host is used (less secure!).
# render_host: codeoceanusercontent.com
flowr: flowr:
# When enabled, flowr can assist learners with related search results from # When enabled, flowr can assist learners with related search results from
# StackOverflow.com regarding exceptions that occurred during code execution. # StackOverflow.com regarding exceptions that occurred during code execution.

View File

@ -38,6 +38,7 @@ Rails.application.config.content_security_policy do |policy|
# Code executions might return a base64 encoded image as a :data URI # Code executions might return a base64 encoded image as a :data URI
policy.img_src :self, :data policy.img_src :self, :data
policy.object_src :none policy.object_src :none
policy.media_src :self
policy.script_src :self, :report_sample policy.script_src :self, :report_sample
# Our ACE editor unfortunately requires :unsafe_inline for the code highlighting # Our ACE editor unfortunately requires :unsafe_inline for the code highlighting
policy.style_src :self, :unsafe_inline, :report_sample policy.style_src :self, :unsafe_inline, :report_sample

View File

@ -129,6 +129,7 @@ Rails.application.routes.draw do
resources :files, only: %i[create destroy] resources :files, only: %i[create destroy]
end end
get '/uploads/files/:id/:filename', to: 'code_ocean/files#show_protected_upload', as: :protected_upload, constraints: {filename: FILENAME_REGEXP} get '/uploads/files/:id/:filename', to: 'code_ocean/files#show_protected_upload', as: :protected_upload, constraints: {filename: FILENAME_REGEXP}
get '/uploads/render_files/:id/:filename', to: 'code_ocean/files#render_protected_upload', as: :render_protected_upload, constraints: {filename: FILENAME_REGEXP}
resources :file_types resources :file_types

View File

@ -40,7 +40,7 @@ describe SubmissionsController do
describe 'GET #download_file' do describe 'GET #download_file' do
context 'with an invalid filename' do context 'with an invalid filename' do
before { get :download_file, params: {filename: SecureRandom.hex, id: submission.id} } before { get :download_file, params: {filename: SecureRandom.hex, id: submission.id, format: :json} }
expect_http_status(:not_found) expect_http_status(:not_found)
end end
@ -108,28 +108,32 @@ describe SubmissionsController do
describe 'GET #render_file' do describe 'GET #render_file' do
let(:file) { submission.files.first } let(:file) { submission.files.first }
let(:signed_url) { AuthenticatedUrlHelper.sign(render_submission_url(submission, filename), submission) }
let(:token) { Rack::Utils.parse_nested_query(URI.parse(signed_url).query)['token'] }
context 'with an invalid filename' do context 'with an invalid filename' do
before { get :render_file, params: {filename: SecureRandom.hex, id: submission.id} } let(:filename) { SecureRandom.hex }
before { get :render_file, params: {filename: filename, id: submission.id, token: token} }
expect_http_status(:not_found) expect_http_status(:not_found)
end end
context 'with a valid filename' do context 'with a valid filename' do
let(:submission) { create(:submission, exercise: create(:audio_video)) } let(:submission) { create(:submission, exercise: create(:audio_video)) }
let(:filename) { file.name_with_extension }
before { get :render_file, params: {filename: file.name_with_extension, id: submission.id} } before { get :render_file, params: {filename: filename, id: submission.id, token: token} }
context 'with a binary file' do context 'with a binary file' do
let(:file) { submission.collect_files.detect {|file| file.file_type.file_extension == '.mp4' } } let(:file) { submission.collect_files.detect {|file| file.file_type.file_extension == '.mp4' } }
let(:signed_url_video) { AuthenticatedUrlHelper.sign(render_protected_upload_url(id: file, filename: file.name_with_extension), file) }
expect_assigns(file: :file) expect_assigns(file: :file)
expect_assigns(submission: :submission) expect_assigns(submission: :submission)
expect_content_type('video/mp4')
expect_http_status(:ok)
it 'renders the file content' do it 'sets the correct redirect' do
expect(response.body).to eq(file.read) expect(response.location).to eq signed_url_video
end end
end end
@ -184,7 +188,7 @@ describe SubmissionsController do
expect_assigns(submission: :submission) expect_assigns(submission: :submission)
expect_http_status(:ok) expect_http_status(:ok)
%i[render run test].each do |action| %i[run test].each do |action|
describe "##{action}_url" do describe "##{action}_url" do
let(:url) { JSON.parse(response.body).with_indifferent_access.fetch("#{action}_url") } let(:url) { JSON.parse(response.body).with_indifferent_access.fetch("#{action}_url") }
@ -199,6 +203,20 @@ describe SubmissionsController do
end end
end end
describe '#render_url' do
let(:supported_urls) { JSON.parse(response.body).with_indifferent_access.fetch('render_url') }
let(:file) { submission.collect_files.detect(&:main_file?) }
let(:url) { supported_urls.find {|hash| hash[:filepath] == file.filepath }['url'] }
it 'starts like the render path' do
expect(url).to start_with(Rails.application.routes.url_helpers.render_submission_url(submission, file.filepath, host: request.host))
end
it 'includes a token' do
expect(url).to include '?token='
end
end
describe '#score_url' do describe '#score_url' do
let(:url) { JSON.parse(response.body).with_indifferent_access.fetch('score_url') } let(:url) { JSON.parse(response.body).with_indifferent_access.fetch('score_url') }