diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index f3e231d9..d5db8f27 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -15,6 +15,11 @@ class ApplicationController < ActionController::Base before_action :set_csp, if: -> { request.get? && !request.headers['HTTP_X_TURBO'] } + # Confines token-bootstrapped embed sessions to their own template. No-op for + # ordinary sessions. Included last so `enforce_embed_scope!` runs after + # `authenticate_user!`. + include EmbedScoped + helper_method :button_title, :current_account, :true_ability, diff --git a/app/controllers/concerns/embed_scoped.rb b/app/controllers/concerns/embed_scoped.rb new file mode 100644 index 00000000..cc60829e --- /dev/null +++ b/app/controllers/concerns/embed_scoped.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +# Confines a token-bootstrapped embed session (see EmbedBuilderController) to +# the single template it was issued for. +# +# A valid first-party DocuSeal session is necessary but not sufficient: the +# session may only reach its own template (by id, or by the immutable +# external_id for the /new -> create -> edit redirect) plus the create + +# builder-support paths the editor needs. Listing / enumeration / other +# templates / submissions / the JSON template API are refused even with a +# valid session, so one embedder can't browse another's documents in the +# shared DocuSeal account. Fails closed. +# +# The path rules mirror the allow-list the embedding app previously enforced +# at the edge via Caddy `forward_auth`; moving them in-process lets the embed +# drop the cross-origin cookie + gate machinery entirely. +module EmbedScoped + extend ActiveSupport::Concern + + SESSION_KEY = 'embed_scope' + SESSION_TTL = 2.hours + + TEMPLATE_PATH = %r{\A/templates/(\d+)(?:[/?]|\z)} + + # Paths an embed session may hit regardless of which template it owns: the + # create/upload flow, the signing/asset surfaces, and the few endpoints the + # builder's own browser code calls. None expose another account's templates. + UNSCOPED_ALLOW = [ + %r{\A/new(?:[/?]|\z)}, + %r{\A/templates/new(?:[/?]|\z)}, + %r{\A/templates_uploads?(?:[/?]|\z)}, + %r{\A/(?:s|d|p|e)(?:[/?]|\z)}, + %r{\A/(?:preview|file|blobs_proxy|submit_form)/}, + %r{\A/verify_pdf_signature(?:[/?]|\z)}, + %r{\A/api/(?:attachments|submitter_email_clicks|submitter_form_views)(?:[/?]|\z)} + ].freeze + + included do + before_action :enforce_embed_scope! + end + + private + + def enforce_embed_scope! + scope = session[SESSION_KEY] + return if scope.blank? + + if embed_scope_expired?(scope) + reset_session + + return redirect_to(new_user_session_path) + end + + head(:forbidden) unless embed_path_authorized?(request.path, scope) + end + + def embed_scope_expired?(scope) + exp = scope['exp'] + + exp.present? && exp.to_i <= Time.now.to_i + end + + def embed_path_authorized?(path, scope) + if (match = TEMPLATE_PATH.match(path)) + return embed_template_in_scope?(match[1].to_i, scope) + end + + UNSCOPED_ALLOW.any? { |re| re.match?(path) } + end + + # A specific /templates/:id is in scope when the session names that template + # directly, or when the template's immutable external_id matches the one the + # token was issued for (covers /new -> create -> redirect to /templates/:id). + def embed_template_in_scope?(id, scope) + return true if scope['template_id'].present? && scope['template_id'].to_i == id + + external_id = scope['external_id'] + return false if external_id.blank? + + Template.where(id:).pick(:external_id) == external_id + end +end diff --git a/app/controllers/embed_builder_controller.rb b/app/controllers/embed_builder_controller.rb new file mode 100644 index 00000000..179ca44c --- /dev/null +++ b/app/controllers/embed_builder_controller.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'jwt' + +# Token-authenticated entry point for the embedded template builder. +# +# The embedding app mints a short-lived JWT signed HS256 with the account's +# API access token — the same key the JSON API authenticates with — and +# renders ``. The self-hosted embed shim +# (`public/js/builder.js`) iframes THIS endpoint, passing the token. +# +# We verify the token against the owner's access token, sign that user in +# (establishing a first-party DocuSeal session inside the iframe), record a +# template-scoped grant in the session (enforced by EmbedScoped), and redirect +# into the regular builder — `/templates/:id/edit` for an existing template, or +# `/new` (download + create from `document_urls`) for a fresh one. The JWT is +# the only credential the embedder handles: no cross-origin cookies, no shared +# admin grant. +class EmbedBuilderController < ApplicationController + skip_before_action :authenticate_user! + skip_authorization_check + + # Refuse tokens whose lifetime exceeds this even if `exp` allows more — an + # embed bootstrap is near-instant, so a long-lived token is pure replay risk. + MAX_TOKEN_TTL = 10.minutes + + def show + payload = verified_payload(params[:token].to_s) + return reject unless payload + + user = User.active.find_by(email: owner_email(payload)) + return reject unless user + + sign_in(user) + + template_id = resolve_template_id(user, payload) + + session[EmbedScoped::SESSION_KEY] = { + 'external_id' => payload['external_id'].presence, + 'template_id' => template_id, + 'exp' => (Time.now + EmbedScoped::SESSION_TTL).to_i + }.compact + + redirect_to(builder_target(template_id, payload)) + end + + private + + # Read the owner email from the UNVERIFIED payload only to look up which + # access token should have signed the token, then verify the signature + # against that key. A token forged for someone else's email fails — the + # attacker doesn't hold that account's key. + def verified_payload(token) + return if token.blank? + + claims = JWT.decode(token, nil, false).first + return unless claims.is_a?(Hash) + + user = User.active.find_by(email: owner_email(claims)) + return unless user + + payload, = JWT.decode(token, user.access_token.token, true, algorithm: 'HS256', verify_expiration: true) + + payload if fresh_enough?(payload) + rescue JWT::DecodeError + nil + end + + # Require an `exp` claim and cap the accepted lifetime — bounds replay + # regardless of what the issuer set. + def fresh_enough?(payload) + exp = payload['exp'] + return false if exp.blank? + + now = Time.now.to_i + + now <= exp.to_i && (exp.to_i - now) <= MAX_TOKEN_TTL.to_i + end + + def owner_email(claims) + claims['user_email'].presence || claims['integration_email'].presence + end + + # Only an id the signed-in user's account actually owns — never trust the + # token's template_id blindly. + def resolve_template_id(user, payload) + id = payload['template_id'] + return if id.blank? + + user.account.templates.where(id:).pick(:id) + end + + def builder_target(template_id, payload) + return edit_template_path(template_id) if template_id + + query = { + url: payload.dig('document_urls', 0), + filename: filename_for(payload), + external_id: payload['external_id'].presence + }.compact + + "/new?#{query.to_query}" + end + + def filename_for(payload) + name = payload['name'].presence || 'Untitled' + + "#{name.gsub(/[^A-Za-z0-9_-]+/, '_')}.pdf" + end + + def reject + redirect_to(new_user_session_path, alert: 'Not authorized') + end +end diff --git a/config/routes.rb b/config/routes.rb index 3ae4c1b3..1b85a68e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -89,6 +89,10 @@ Rails.application.routes.draw do authenticated do resource :templates_upload, only: %i[show], path: 'new' end + # Token-authenticated entry for the embedded builder (verifies a JWT signed + # with the account API key, signs the owner in, then redirects into the + # builder). Public on purpose — the token IS the credential. + get 'embed/builder', to: 'embed_builder#show', as: :embed_builder resources :templates_archived, only: %i[index], path: 'templates/archived' resources :folders, only: %i[show edit update destroy], controller: 'template_folders' resources :template_sharings_testing, only: %i[create] diff --git a/public/js/builder.js b/public/js/builder.js index 7e13c759..d5432cd7 100644 --- a/public/js/builder.js +++ b/public/js/builder.js @@ -5,32 +5,17 @@ // // What this does: // 1. Reads the JWT minted by the embedding app from `data-token`. -// 2. Decodes its payload to discover `document_urls`, `name`, -// `external_id`, and `template_id`. -// 3. Iframes either `/templates/:id/edit` (existing template) or -// `/new?url=...&filename=...&external_id=...` (fresh upload flow). -// 4. Relays the inner builder's `save` postMessage up as a DOM -// `CustomEvent('save')` on the outer element so `@docuseal/react`'s -// `onSave` callback fires. -// -// JWT signature is NOT verified here — host DocuSeal session cookies carry -// auth, and the calling app signs with the shared secret it controls. +// 2. Iframes the token-authenticated entry point `/embed/builder?token=…`. +// That endpoint verifies the JWT against the account API key, signs the +// owner in (a first-party DocuSeal session, scoped to one template), and +// redirects into the builder — `/templates/:id/edit` for an existing +// template, or `/new?url=…` for a fresh upload. The token is the only +// credential; no cross-origin cookies are involved. +// 3. Relays the inner builder's `save` postMessage up as a DOM +// `CustomEvent('save')` so `@docuseal/react`'s `onSave` callback fires. (function () { - function decodeJwtPayload(token) { - try { - var part = token.split('.')[1] - if (!part) return null - var b64 = part.replace(/-/g, '+').replace(/_/g, '/') - var pad = b64.length % 4 - if (pad) b64 += '='.repeat(4 - pad) - return JSON.parse(decodeURIComponent(escape(atob(b64)))) - } catch (e) { - return null - } - } - - // Discover the DocuSeal host from this script's own src so embedders - // don't have to set data-host on every element. + // Discover the DocuSeal host from this script's own src so embedders don't + // have to set data-host on every element. var SCRIPT_HOST = (function () { try { var current = document.currentScript @@ -50,10 +35,9 @@ } })() - // Relay `save` postMessage from the iframe (templates_builder fires it - // after a successful manual SAVE) up as a DOM `save` CustomEvent on the - // outer element so React listeners can close the - // embedding sheet. + // Relay `save` postMessage from the iframe (the builder fires it after a + // successful manual SAVE) up as a DOM `save` CustomEvent on the outer + // element so React listeners can close the embedding sheet. if (!window.__docusealEmbedListenerInstalled) { window.__docusealEmbedListenerInstalled = true window.addEventListener('message', function (ev) { @@ -81,27 +65,12 @@ if (!token) return this._mounted = true var host = this.getAttribute('data-host') || SCRIPT_HOST || window.location.host - var payload = decodeJwtPayload(token) || {} - var docUrl = (payload.document_urls && payload.document_urls[0]) || '' - var name = payload.name || 'Untitled' - var filename = name.replace(/[^A-Za-z0-9_\-]+/g, '_') + '.pdf' - // Match parent page scheme. Both ends run TLS locally (mkcert), so - // https parent -> https iframe with no mixed-content blocking. + // Match parent page scheme. Both ends run TLS, so https parent -> https + // iframe with no mixed-content blocking. var origin = window.location.protocol + '//' + host - var src - if (payload.template_id) { - // Existing template: open the editor directly so prior field - // edits are preserved. Posting to /new would create another - // template and orphan the original. - src = origin + '/templates/' + encodeURIComponent(payload.template_id) + '/edit' - } else { - var qs = new URLSearchParams() - if (docUrl) qs.set('url', docUrl) - qs.set('filename', filename) - if (payload.external_id) qs.set('external_id', payload.external_id) - src = origin + '/new?' + qs.toString() - } + var src = origin + '/embed/builder?token=' + encodeURIComponent(token) + var iframe = document.createElement('iframe') iframe.src = src iframe.style.cssText = 'width:100%;height:100%;min-height:600px;border:0;display:block' diff --git a/spec/requests/embed_builder_spec.rb b/spec/requests/embed_builder_spec.rb new file mode 100644 index 00000000..16697a15 --- /dev/null +++ b/spec/requests/embed_builder_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'jwt' + +describe 'Embed builder' do + let(:account) { create(:account) } + let(:user) { create(:user, account:) } + let(:api_key) { user.access_token.token } + + def token(claims = {}) + JWT.encode({ user_email: user.email, exp: 5.minutes.from_now.to_i }.merge(claims), api_key, 'HS256') + end + + describe 'GET /embed/builder' do + it 'signs the owner in and redirects to /new for a fresh template' do + get embed_builder_path, params: { + token: token(document_urls: ['https://example.com/doc.pdf'], name: 'Consent Form', external_id: 'ext-123') + } + + expect(response).to have_http_status(:found) + expect(response.location).to include('/new') + expect(response.location).to include('external_id=ext-123') + expect(response.location).to include('Consent_Form.pdf') + end + + it 'redirects to the editor for an existing owned template' do + template = create(:template, account:, external_id: 'ext-xyz') + + get embed_builder_path, params: { token: token(template_id: template.id) } + + expect(response).to redirect_to(edit_template_path(template)) + end + + it 'never opens a template the account does not own — falls back to create' do + other = create(:template, account: create(:account), external_id: 'foreign') + + get embed_builder_path, params: { token: token(template_id: other.id) } + + expect(response.location).to include('/new') + expect(response.location).not_to include("/templates/#{other.id}") + end + + it 'rejects a token signed with the wrong key' do + bad = JWT.encode({ user_email: user.email, exp: 5.minutes.from_now.to_i }, 'not-the-key', 'HS256') + + get embed_builder_path, params: { token: bad } + + expect(response).to redirect_to(new_user_session_path) + end + + it 'rejects an expired token' do + get embed_builder_path, params: { token: token(exp: 1.minute.ago.to_i) } + + expect(response).to redirect_to(new_user_session_path) + end + + it 'rejects a token with no exp claim' do + bare = JWT.encode({ user_email: user.email }, api_key, 'HS256') + + get embed_builder_path, params: { token: bare } + + expect(response).to redirect_to(new_user_session_path) + end + + it 'rejects a token whose lifetime exceeds the cap' do + get embed_builder_path, params: { token: token(exp: 1.hour.from_now.to_i) } + + expect(response).to redirect_to(new_user_session_path) + end + + it 'rejects a token for an unknown owner' do + foreign = JWT.encode({ user_email: 'nobody@example.com', exp: 5.minutes.from_now.to_i }, api_key, 'HS256') + + get embed_builder_path, params: { token: foreign } + + expect(response).to redirect_to(new_user_session_path) + end + end + + describe 'scope enforcement (EmbedScoped)' do + it 'confines the embed session to its own template' do + mine = create(:template, account:, external_id: 'mine') + theirs = create(:template, account:, external_id: 'theirs') + + get embed_builder_path, params: { token: token(template_id: mine.id) } + expect(response).to redirect_to(edit_template_path(mine)) + + # Cookies persist across requests within a request spec, so the next + # call rides the embed session established above. + get edit_template_path(mine) + expect(response).to have_http_status(:ok) + + get edit_template_path(theirs) + expect(response).to have_http_status(:forbidden) + end + + it 'refuses template enumeration even with a valid embed session' do + mine = create(:template, account:, external_id: 'mine') + + get embed_builder_path, params: { token: token(template_id: mine.id) } + get '/templates' + + expect(response).to have_http_status(:forbidden) + end + end +end