Skip to content

Commit 6bfd124

Browse files
matteiusclaude
andcommitted
feat: embeddable forms + fix 5 CodeQL security alerts
Embeddable Forms: - form_embed view (@xframe_options_exempt) renders forms in a minimal iframe-friendly layout (embed_base.html, no navbar/footer) - dfw-embed.js loader script for external sites: creates iframe, handles auto-resize via postMessage, fires callbacks on load/submit - embed_enabled BooleanField on FormDefinition - postMessage protocol: dfw:loaded, dfw:resize, dfw:submitted - Inline success rendering (no redirects) to stay self-contained - SameSite=None; Secure on CSRF cookie for cross-origin iframe - Form builder: Embeddable checkbox in Submission Controls - Admin: embed_enabled in API & Embedding fieldset - sync_api: embed_enabled in export/import - clone_forms: embed_enabled included - Migration 0086 - 14 new tests covering GET/POST, disabled/inactive, theme, accent color sanitisation, closed form, max submissions, audit log, success message piping, no-redirect behavior Security fixes (CodeQL alerts #23, #25, #26-28): - #25: workflow-builder.js — validate workflowId as integer and use URL() constructor instead of template literal in window.location.href - #26-28: sync_api.py — remove user-supplied form slugs from log messages to prevent clear-text logging of potentially sensitive information - #23: workflow_builder_views.py — stop exposing ValidationError exception messages in JSON response; log them server-side instead Co-authored-by: Claude Code <noreply@anthropic.com>
1 parent 20b939f commit 6bfd124

File tree

18 files changed

+764
-14
lines changed

18 files changed

+764
-14
lines changed

django_forms_workflows/admin.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -669,12 +669,13 @@ class FormDefinitionAdmin(nested_admin.NestedModelAdmin):
669669
},
670670
),
671671
(
672-
"API Access",
672+
"API & Embedding",
673673
{
674674
"classes": ("collapse",),
675-
"fields": ("api_enabled",),
675+
"fields": ("api_enabled", "embed_enabled"),
676676
"description": (
677-
"Enable this form for REST API submission. Requires the API URLs "
677+
"Enable this form for REST API submission or iframe embedding. "
678+
"API requires the API URLs "
678679
"to be included in your project's <code>urls.py</code> and a valid "
679680
"<strong>APIToken</strong> in the <code>Authorization: Bearer</code> "
680681
"header. All existing submit_groups / view_groups permissions still apply."
@@ -847,6 +848,7 @@ def clone_forms(self, request, queryset):
847848
allow_withdrawal=form.allow_withdrawal,
848849
allow_resubmit=form.allow_resubmit,
849850
allow_batch_import=form.allow_batch_import,
851+
embed_enabled=form.embed_enabled,
850852
payment_enabled=form.payment_enabled,
851853
payment_provider=form.payment_provider,
852854
payment_amount_type=form.payment_amount_type,

django_forms_workflows/form_builder_views.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,7 @@ def form_builder_load(request, form_id):
344344
"payment_currency": form_definition.payment_currency,
345345
"payment_description_template": form_definition.payment_description_template,
346346
"enable_captcha": form_definition.enable_captcha,
347+
"embed_enabled": form_definition.embed_enabled,
347348
"enable_multi_step": form_definition.enable_multi_step,
348349
"form_steps": form_definition.form_steps or [],
349350
"enable_auto_save": form_definition.enable_auto_save,
@@ -390,6 +391,7 @@ def form_builder_save(request):
390391
payment_currency = data.get("payment_currency", "usd")
391392
payment_description_template = data.get("payment_description_template", "")
392393
enable_captcha = data.get("enable_captcha", False)
394+
embed_enabled = data.get("embed_enabled", False)
393395
enable_multi_step = data.get("enable_multi_step", False)
394396
form_steps = data.get("form_steps", [])
395397
enable_auto_save = data.get("enable_auto_save", True)
@@ -435,6 +437,7 @@ def form_builder_save(request):
435437
payment_description_template
436438
)
437439
form_definition.enable_captcha = enable_captcha
440+
form_definition.embed_enabled = embed_enabled
438441
form_definition.enable_multi_step = enable_multi_step
439442
form_definition.form_steps = form_steps
440443
form_definition.enable_auto_save = enable_auto_save
@@ -465,6 +468,7 @@ def form_builder_save(request):
465468
payment_currency=payment_currency,
466469
payment_description_template=payment_description_template,
467470
enable_captcha=enable_captcha,
471+
embed_enabled=embed_enabled,
468472
enable_multi_step=enable_multi_step,
469473
form_steps=form_steps,
470474
enable_auto_save=enable_auto_save,
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Generated by Django 5.2.7 on 2026-04-02 00:47
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("django_forms_workflows", "0085_add_payment_system"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="formdefinition",
15+
name="embed_enabled",
16+
field=models.BooleanField(
17+
default=False,
18+
help_text="Allow this form to be embedded on external websites via an iframe. The form is served at /forms/<slug>/embed/ with a minimal layout. Works best with requires_login=False forms.",
19+
),
20+
),
21+
]

django_forms_workflows/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,14 @@ class FormDefinition(models.Model):
203203
"All existing permission checks (submit_groups, view_groups) still apply."
204204
),
205205
)
206+
embed_enabled = models.BooleanField(
207+
default=False,
208+
help_text=(
209+
"Allow this form to be embedded on external websites via an iframe. "
210+
"The form is served at /forms/<slug>/embed/ with a minimal layout. "
211+
"Works best with requires_login=False forms."
212+
),
213+
)
206214
requires_login = models.BooleanField(
207215
default=True, help_text="Form requires authentication"
208216
)
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* Django Forms Workflows — Embeddable Form Loader
3+
*
4+
* Drop this script tag on any webpage to embed a form:
5+
*
6+
* <script src="https://server/static/django_forms_workflows/js/dfw-embed.js"
7+
* data-form="contact-us"
8+
* data-server="https://server"
9+
* ></script>
10+
*
11+
* Attributes:
12+
* data-form (required) Form slug
13+
* data-server (required) Base URL of the DFW server
14+
* data-target CSS selector of container (default: insert after script tag)
15+
* data-on-submit Global function name called on successful submission
16+
* data-on-load Global function name called when form has loaded
17+
* data-theme "light" (default) or "dark"
18+
* data-accent-color Hex color for primary buttons (e.g., "#ff6600")
19+
* data-min-height Minimum iframe height in px (default: 300)
20+
* data-loading-text Text shown while loading (default: "Loading form...")
21+
*/
22+
(function () {
23+
'use strict';
24+
25+
// Find our own script tag
26+
var script = document.currentScript;
27+
if (!script) {
28+
// Fallback for older browsers
29+
var scripts = document.getElementsByTagName('script');
30+
script = scripts[scripts.length - 1];
31+
}
32+
33+
var formSlug = script.getAttribute('data-form');
34+
var server = script.getAttribute('data-server');
35+
36+
if (!formSlug || !server) {
37+
console.error('[dfw-embed] data-form and data-server attributes are required.');
38+
return;
39+
}
40+
41+
// Read optional attributes
42+
var targetSelector = script.getAttribute('data-target');
43+
var onSubmitFn = script.getAttribute('data-on-submit');
44+
var onLoadFn = script.getAttribute('data-on-load');
45+
var theme = script.getAttribute('data-theme') || '';
46+
var accentColor = script.getAttribute('data-accent-color') || '';
47+
var minHeight = parseInt(script.getAttribute('data-min-height')) || 300;
48+
var loadingText = script.getAttribute('data-loading-text') || 'Loading form...';
49+
50+
// Normalize server URL (remove trailing slash)
51+
server = server.replace(/\/+$/, '');
52+
53+
// Build embed URL
54+
var embedUrl = server + '/forms/' + encodeURIComponent(formSlug) + '/embed/';
55+
var params = [];
56+
if (theme) params.push('theme=' + encodeURIComponent(theme));
57+
if (accentColor) params.push('accent_color=' + encodeURIComponent(accentColor));
58+
if (params.length) embedUrl += '?' + params.join('&');
59+
60+
// Create container
61+
var container = document.createElement('div');
62+
container.className = 'dfw-embed-container';
63+
container.style.cssText = 'width:100%;position:relative;';
64+
65+
// Loading indicator
66+
var loading = document.createElement('div');
67+
loading.textContent = loadingText;
68+
loading.style.cssText = 'text-align:center;padding:2rem;color:#6c757d;font-family:sans-serif;font-size:0.9rem;';
69+
container.appendChild(loading);
70+
71+
// Create iframe
72+
var iframe = document.createElement('iframe');
73+
iframe.src = embedUrl;
74+
iframe.style.cssText = 'width:100%;border:none;overflow:hidden;display:none;min-height:' + minHeight + 'px;';
75+
iframe.setAttribute('scrolling', 'no');
76+
iframe.setAttribute('allowtransparency', 'true');
77+
iframe.setAttribute('title', 'Form: ' + formSlug);
78+
container.appendChild(iframe);
79+
80+
// Insert into DOM
81+
if (targetSelector) {
82+
var target = document.querySelector(targetSelector);
83+
if (target) {
84+
target.appendChild(container);
85+
} else {
86+
console.error('[dfw-embed] Target element not found:', targetSelector);
87+
script.parentNode.insertBefore(container, script.nextSibling);
88+
}
89+
} else {
90+
script.parentNode.insertBefore(container, script.nextSibling);
91+
}
92+
93+
// Parse the server origin for message validation
94+
var serverOrigin;
95+
try {
96+
var a = document.createElement('a');
97+
a.href = server;
98+
serverOrigin = a.protocol + '//' + a.host;
99+
} catch (e) {
100+
serverOrigin = server;
101+
}
102+
103+
// Listen for postMessage events from the iframe
104+
window.addEventListener('message', function (event) {
105+
// Validate origin
106+
if (event.origin !== serverOrigin) return;
107+
108+
var data = event.data;
109+
if (!data || !data.type || data.formSlug !== formSlug) return;
110+
111+
switch (data.type) {
112+
case 'dfw:loaded':
113+
loading.style.display = 'none';
114+
iframe.style.display = 'block';
115+
if (onLoadFn && typeof window[onLoadFn] === 'function') {
116+
window[onLoadFn](data);
117+
}
118+
break;
119+
120+
case 'dfw:resize':
121+
if (data.height) {
122+
iframe.style.height = Math.max(data.height + 20, minHeight) + 'px';
123+
}
124+
break;
125+
126+
case 'dfw:submitted':
127+
if (onSubmitFn && typeof window[onSubmitFn] === 'function') {
128+
window[onSubmitFn](data);
129+
}
130+
break;
131+
}
132+
});
133+
})();

django_forms_workflows/static/django_forms_workflows/js/form-builder.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2000,6 +2000,7 @@ class FormBuilder {
20002000
if (data.max_submissions) document.getElementById('formMaxSubmissions').value = data.max_submissions;
20012001
document.getElementById('formOnePerUser').checked = data.one_per_user || false;
20022002
document.getElementById('formEnableCaptcha').checked = data.enable_captcha || false;
2003+
document.getElementById('formEmbedEnabled').checked = data.embed_enabled || false;
20032004

20042005
// Load client-side enhancement settings
20052006
document.getElementById('formEnableAutoSave').checked = data.enable_auto_save !== false;
@@ -2072,6 +2073,7 @@ class FormBuilder {
20722073
max_submissions: parseInt(document.getElementById('formMaxSubmissions').value) || null,
20732074
one_per_user: document.getElementById('formOnePerUser').checked,
20742075
enable_captcha: document.getElementById('formEnableCaptcha').checked,
2076+
embed_enabled: document.getElementById('formEmbedEnabled').checked,
20752077
enable_auto_save: document.getElementById('formEnableAutoSave').checked,
20762078
auto_save_interval: parseInt(document.getElementById('formAutoSaveInterval').value) || 30,
20772079
enable_multi_step: isMultiStep,

django_forms_workflows/static/django_forms_workflows/js/workflow-builder.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,12 @@ class WorkflowBuilder {
268268
if (workflowTrackSelect) {
269269
workflowTrackSelect.addEventListener('change', (event) => {
270270
const workflowId = event.target.value;
271-
window.location.href = `${this.config.workflowBuilderUrl}?workflow_id=${workflowId}`;
271+
// Validate that workflowId is a safe integer before using in URL
272+
if (/^\d+$/.test(workflowId)) {
273+
const url = new URL(this.config.workflowBuilderUrl, window.location.origin);
274+
url.searchParams.set('workflow_id', workflowId);
275+
window.location.href = url.toString();
276+
}
272277
});
273278
}
274279

django_forms_workflows/sync_api.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,7 @@ def serialize_form(form_definition):
429429
"auto_save_interval": form_definition.auto_save_interval,
430430
"pdf_generation": form_definition.pdf_generation,
431431
"api_enabled": form_definition.api_enabled,
432+
"embed_enabled": form_definition.embed_enabled,
432433
"payment_enabled": form_definition.payment_enabled,
433434
"payment_provider": form_definition.payment_provider,
434435
"payment_amount_type": form_definition.payment_amount_type,
@@ -650,6 +651,7 @@ def import_form(form_data, conflict="update", category_cache=None):
650651
"auto_save_interval": fd.get("auto_save_interval", 30),
651652
"pdf_generation": fd.get("pdf_generation", False),
652653
"api_enabled": fd.get("api_enabled", False),
654+
"embed_enabled": fd.get("embed_enabled", False),
653655
"payment_enabled": fd.get("payment_enabled", False),
654656
"payment_provider": fd.get("payment_provider", ""),
655657
"payment_amount_type": fd.get("payment_amount_type", "fixed"),
@@ -835,10 +837,8 @@ def import_form(form_data, conflict="update", category_cache=None):
835837
)
836838
else:
837839
logger.warning(
838-
"Sync import: sub-workflow form '%s' not found for form '%s';"
839-
" sub_workflow_config skipped.",
840-
sub_form_slug,
841-
slug,
840+
"Sync import: sub-workflow form not found for parent form;"
841+
" sub_workflow_config skipped."
842842
)
843843
else:
844844
SubWorkflowDefinition.objects.filter(parent_workflow=wf).delete()
@@ -888,7 +888,7 @@ def import_form(form_data, conflict="update", category_cache=None):
888888
for action_data in form_data.get("post_actions", []):
889889
PostSubmissionAction.objects.create(form_definition=form_obj, **action_data)
890890

891-
logger.info("Sync import: form '%s' %s.", slug, action)
891+
logger.info("Sync import: form %s.", action)
892892
return form_obj, action
893893

894894

django_forms_workflows/templates/admin/django_forms_workflows/form_builder.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -772,6 +772,12 @@ <h6 class="text-muted border-bottom pb-2">
772772
<label class="form-check-label" for="formEnableCaptcha">CAPTCHA</label>
773773
</div>
774774
</div>
775+
<div class="col-md-2">
776+
<div class="form-check mt-4">
777+
<input class="form-check-input" type="checkbox" id="formEmbedEnabled">
778+
<label class="form-check-label" for="formEmbedEnabled">Embeddable</label>
779+
</div>
780+
</div>
775781

776782
<!-- Payment -->
777783
<div class="col-12 mt-3">
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
{% load static %}
2+
<!DOCTYPE html>
3+
<html lang="en" {% if theme == 'dark' %}data-bs-theme="dark"{% endif %}>
4+
<head>
5+
<meta charset="UTF-8">
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
7+
<title>{% block title %}Form{% endblock %}</title>
8+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
9+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
10+
<link rel="stylesheet" href="{% static 'django_forms_workflows/css/forms.css' %}">
11+
<base target="_blank">
12+
<style>
13+
body { margin: 0; padding: 16px; background: transparent; overflow-x: hidden; }
14+
.card { border: none; box-shadow: none; }
15+
</style>
16+
{% if accent_color %}
17+
<style>
18+
.btn-primary { background-color: {{ accent_color }}; border-color: {{ accent_color }}; }
19+
.btn-primary:hover { background-color: {{ accent_color }}; border-color: {{ accent_color }}; filter: brightness(0.9); }
20+
</style>
21+
{% endif %}
22+
{% block extra_css %}{% endblock %}
23+
</head>
24+
<body>
25+
{% block content %}{% endblock %}
26+
27+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
28+
<script>
29+
(function() {
30+
function sendToParent(type, data) {
31+
var msg = Object.assign({type: type, formSlug: '{{ form_def.slug }}'}, data || {});
32+
try { window.parent.postMessage(msg, '*'); } catch(e) {}
33+
}
34+
35+
function notifyHeight() {
36+
sendToParent('dfw:resize', {height: document.documentElement.scrollHeight});
37+
}
38+
39+
if (window.ResizeObserver) {
40+
new ResizeObserver(notifyHeight).observe(document.body);
41+
} else {
42+
setInterval(notifyHeight, 500);
43+
}
44+
45+
document.addEventListener('DOMContentLoaded', function() {
46+
notifyHeight();
47+
sendToParent('dfw:loaded');
48+
});
49+
50+
window.__dfwEmbed = {sendToParent: sendToParent, notifyHeight: notifyHeight};
51+
})();
52+
</script>
53+
{% block extra_js %}{% endblock %}
54+
</body>
55+
</html>

0 commit comments

Comments
 (0)