Self-hosted embed: JWT shim + Rails-side glue for cross-origin iframe

Lets the EnWella EHR mount <docuseal-builder> (and -form) without a
paid Pro license. Upstream's `EmbedScriptsController` returns an
"Upgrade to Pro" stub; we override it via the static-asset path so the
real shim wins without touching the controller.

JS shim (public/js/builder.js)
- Web component that decodes the JWT minted by the embedder (`name`,
  `document_urls`, `external_id`, optional `template_id`).
- Iframes /templates/:id/edit for existing templates, /new?... for the
  upload-from-URL flow.
- Relays a `save` postMessage from the inner builder up as a DOM
  `CustomEvent('save')` on the outer element so @docuseal/react's
  onSave callback fires.
- Discovers host from its own <script src> so embedders don't have to
  set data-host. JWT signature not re-verified (session cookies +
  shared HMAC at the calling app are authoritative).

Builder (app/javascript/template_builder/builder.vue)
- isEmbedded computed (window.parent !== window).
- Hide #title_container in embed (sheet already shows the consent name).
- Fire the `save` postMessage at the end of the manual SAVE handler so
  the outer shim can dispatch the DOM event.

Rails glue
- application_controller.rb / config/application.rb: when
  EMBED_ALLOWED_ORIGIN is set, add `frame-ancestors 'self' <origin>`
  to the per-request CSP and drop the default `X-Frame-Options:
  SAMEORIGIN` header (different scheme/host/port = different origin,
  so SAMEORIGIN blocks the EHR's iframe even in same-domain prod).
- templates_uploads_controller.rb: persist `external_id` from the
  upload params so the host app can later look the template up via
  `GET /api/templates?external_id=...` (deterministic fallback link
  path when the `template.created` webhook doesn't reach us).
- templates_uploads_controller.rb: skip the SSRF guard when fetching
  documents in development — it rejects http / non-443 / localhost,
  which is exactly the Active Storage URLs the EHR sends from
  https://localhost:3000.
- templates_uploads/show.html.erb: carry `external_id` through the
  resubmit form (used on the encrypted-PDF prompt path).

Local HTTPS dev (puma.rb, Procfile.dev, .env.example)
- Bind ssl://0.0.0.0:PORT when LOCAL_HTTPS_CERT/KEY are set (wired by
  ../bin/docuseal-dev to ../.certs/lvh.me.pem). EHR runs HTTPS too,
  so HTTPS iframe avoids mixed-content blocking.
- Procfile.dev runs `puma -C config/puma.rb` directly so the ssl bind
  applies.
- SSL_CERT_FILE example for trusting the mkcert root so DocuSeal's
  outbound HTTPS to Rails (Active Storage downloads) verifies.
pull/697/head
Vadym Shaveiko 3 months ago
parent 4f50df8f76
commit f4324122cc

@ -6,7 +6,17 @@ PORT=3030
# Postgres dev DB. Create with: createdb docuseal_dev
DATABASE_URL=postgres:///docuseal_dev
# Allow framing from this origin (EHR dev URL). Drops X-Frame-Options and
# adds `Content-Security-Policy: frame-ancestors 'self' <origin>`.
# Vite serves HTTPS via mkcert locally, so use https://.
EMBED_ALLOWED_ORIGIN=https://localhost:4500
# Webpacker / shakapacker dev-server port (default 3035; left alone).
# Trust mkcert CA so DocuSeal can fetch uploaded PDFs from Rails over
# https://localhost:3000. Ruby OpenSSL ignores macOS keychain — set this
# to the mkcert root CA path (output of `mkcert -CAROOT`/rootCA.pem).
# SSL_CERT_FILE=/Users/you/Library/Application Support/mkcert/rootCA.pem
# Override host base URL used in generated links if needed.
# HOST=http://localhost:3030
# HOST=https://localhost:3030

@ -1,2 +1,2 @@
web: PORT=${PORT:-3000} bundle exec rails s -p ${PORT:-3000}
web: PORT=${PORT:-3000} bundle exec puma -C config/puma.rb
webpacker: bundle exec ./bin/shakapacker-dev-server

@ -141,6 +141,11 @@ class ApplicationController < ActionController::Base
policy.connect_src :self
policy.directives['connect-src'] << 'ws:' if Rails.env.development?
# Allow the embedding app (set via EMBED_ALLOWED_ORIGIN) to iframe
# this DocuSeal instance. Required by the self-hosted JWT shim in
# `embed_scripts_controller.rb`.
policy.frame_ancestors :self, ENV['EMBED_ALLOWED_ORIGIN'] if ENV['EMBED_ALLOWED_ORIGIN'].present?
end
end
end

@ -45,6 +45,10 @@ class TemplatesUploadsController < ApplicationController
template.author = current_user
template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
template.name = File.basename((url_params || params)[:files].first.original_filename, '.*')
# Persist the embedder-supplied external_id so the host app can later
# query `/api/templates?external_id=...` to reconcile its own record
# with this template (used by the consent-template fallback link path).
template.external_id = params[:external_id] if params[:external_id].present?
Templates.maybe_assign_access(template)
@ -56,7 +60,10 @@ class TemplatesUploadsController < ApplicationController
def create_file_params_from_url
tempfile = Tempfile.new
tempfile.binmode
tempfile.write(DownloadUtils.call(params[:url], validate: true).body)
# Validation rejects http, non-443 ports, and any localhost — which is
# exactly what the embedding EHR sends in dev (https://localhost:3000
# Active Storage URL). Skip the SSRF guard locally; keep it on in prod.
tempfile.write(DownloadUtils.call(params[:url], validate: !Rails.env.local?).body)
tempfile.rewind
filename = URI.decode_www_form_component(params[:filename]) if params[:filename].present?

@ -76,7 +76,7 @@
</div>
</div>
<div
v-if="$slots.buttons || withTitle"
v-if="($slots.buttons || withTitle) && !isEmbedded"
id="title_container"
class="flex justify-between py-1.5 items-center pr-4 top-0 z-10 title-container"
:class="{ sticky: withStickySubmitters || isBreakpointLg }"
@ -1073,6 +1073,9 @@ export default {
fieldsDragFieldRef: () => ref(),
customDragFieldRef: () => ref(),
selectedAreasRef: () => ref([]),
isEmbedded () {
return typeof window !== 'undefined' && window.parent !== window
},
attachmentUuidsIndex () {
return this.template.schema.reduce((acc, e, index) => {
acc[e.attachment_uuid] = index
@ -3142,6 +3145,9 @@ export default {
const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody())
Promise.all([this.save({ force: true, revision: this.withRevisions }), ...dynamicDocumentSaves]).then(() => {
if (window.parent !== window) {
window.parent.postMessage({ source: 'docuseal-embed', type: 'save', template_id: this.template.id }, '*')
}
window.Turbo.visit(`/templates/${this.template.id}`)
}).finally(() => {
this.isSaving = false

@ -14,5 +14,6 @@
<button type="submit"></button>
<input name="url" value="<%= params[:url] %>">
<input name="filename" value="<%= params[:filename] %>">
<input name="external_id" value="<%= params[:external_id] %>">
<% end %>
</submit-form>

@ -34,6 +34,15 @@ module DocuSeal
config.content_security_policy_nonce_generator = ->(_) { SecureRandom.base64(16) }
config.content_security_policy_nonce_directives = %w[script-src]
# When configured as an embed target, drop the default
# `X-Frame-Options: SAMEORIGIN` header so CSP `frame-ancestors`
# (set per-request in `ApplicationController#set_csp`) governs framing.
# Modern browsers prefer `frame-ancestors`, but X-Frame-Options still
# blocks if present alongside it.
if ENV['EMBED_ALLOWED_ORIGIN'].present?
config.action_dispatch.default_headers.delete('X-Frame-Options')
end
config.action_view.frozen_string_literal = true
config.middleware.insert_before ActionDispatch::Static, Rack::Deflater

@ -17,9 +17,19 @@ threads min_threads_count, max_threads_count
#
worker_timeout 3600 if ENV.fetch('RAILS_ENV', 'development') == 'development'
# Local HTTPS via mkcert. Enable by pointing `LOCAL_HTTPS_CERT` / `LOCAL_HTTPS_KEY`
# at PEM files (see ../bin/docuseal-dev which wires them to ../.certs/lvh.me*).
# Embedding app (Vite) is served HTTPS, so an HTTP DocuSeal iframe is blocked
# as mixed content — running DocuSeal over TLS lets the parent iframe load it.
ssl_cert = ENV['LOCAL_HTTPS_CERT'].to_s
ssl_key = ENV['LOCAL_HTTPS_KEY'].to_s
if !ssl_cert.empty? && !ssl_key.empty? && File.exist?(ssl_cert) && File.exist?(ssl_key)
bind "ssl://0.0.0.0:#{ENV.fetch('PORT', 3000)}?cert=#{ssl_cert}&key=#{ssl_key}"
else
# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
#
port ENV.fetch('PORT', 3000)
end
# Specifies the `environment` that Puma will run in.
#

@ -0,0 +1,122 @@
// Self-hosted embed shim for the <docuseal-builder> web component.
// Upstream `EmbedScriptsController#show` returns an "Upgrade to Pro" stub;
// the Rails static middleware serves this file ahead of that route, so the
// real shim wins without touching the controller.
//
// 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.
(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.
var SCRIPT_HOST = (function () {
try {
var current = document.currentScript
var src = current ? current.src : ''
if (!src) {
var all = document.getElementsByTagName('script')
for (var i = 0; i < all.length; i++) {
if (/\/js\/(builder|form)\.js/.test(all[i].src)) {
src = all[i].src
break
}
}
}
return src ? new URL(src).host : ''
} catch (e) {
return ''
}
})()
// Relay `save` postMessage from the iframe (templates_builder fires it
// after a successful manual SAVE) up as a DOM `save` CustomEvent on the
// outer <docuseal-builder> element so React listeners can close the
// embedding sheet.
if (!window.__docusealEmbedListenerInstalled) {
window.__docusealEmbedListenerInstalled = true
window.addEventListener('message', function (ev) {
var d = ev && ev.data
if (!d || d.source !== 'docuseal-embed' || d.type !== 'save') return
document.querySelectorAll('docuseal-builder, docuseal-form').forEach(function (el) {
el.dispatchEvent(new CustomEvent('save', { detail: { template_id: d.template_id } }))
})
})
}
var EmbedBuilder = class extends HTMLElement {
static get observedAttributes() {
return ['data-token', 'data-host']
}
connectedCallback() {
this._maybeMount()
}
attributeChangedCallback() {
this._maybeMount()
}
_maybeMount() {
if (this._mounted) return
var token = this.getAttribute('data-token') || ''
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.
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 iframe = document.createElement('iframe')
iframe.src = src
iframe.style.cssText = 'width:100%;height:100%;min-height:600px;border:0;display:block'
iframe.setAttribute('allow', 'clipboard-write')
this.style.display = 'block'
this.style.height = this.style.height || '100%'
this.appendChild(iframe)
}
}
if (!window.customElements.get('docuseal-builder')) {
window.customElements.define('docuseal-builder', EmbedBuilder)
}
if (!window.customElements.get('docuseal-form')) {
window.customElements.define('docuseal-form', class extends EmbedBuilder {})
}
})()
Loading…
Cancel
Save