Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Drag-and-drop workflow builder for post-submission actions and approvals.
*/

class WorkflowBuilder {
export class WorkflowBuilder {
constructor(config) {
this.config = config;
this.nodes = [];
Expand Down Expand Up @@ -1784,7 +1784,7 @@ class WorkflowBuilder {

fields.forEach(field => {
const prefillBadge = field.prefill_source ?
`<span class="badge bg-info ms-2" title="Auto-filled from ${field.prefill_source}"><i class="bi bi-magic"></i> ${field.prefill_source}</span>` : '';
`<span class="badge bg-info ms-2" title="Auto-filled from ${this.escapeHtml(field.prefill_source)}"><i class="bi bi-magic"></i> ${this.escapeHtml(field.prefill_source)}</span>` : '';
const requiredBadge = field.required ?
`<span class="badge bg-warning ms-1">Required</span>` : '';

Expand All @@ -1793,7 +1793,7 @@ class WorkflowBuilder {
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>${this.escapeHtml(field.label)}</strong>
<small class="text-muted d-block">${field.name} (${field.type})</small>
<small class="text-muted d-block">${this.escapeHtml(field.name)} (${this.escapeHtml(field.type)})</small>
</div>
<div>
${requiredBadge}
Expand Down Expand Up @@ -2893,10 +2893,10 @@ class WorkflowBuilder {
return `${badgeHtml}${fieldCount} field${fieldCount !== 1 ? 's' : ''} • <a href="${node.data.form_builder_url || '#'}" target="_blank" class="text-primary form-edit-link"><i class="bi bi-pencil-square"></i> Edit Form</a>`;
case 'workflow_settings':
const parts_ws = [];
if (node.data.name_label) parts_ws.push(node.data.name_label);
if (node.data.name_label) parts_ws.push(this.escapeHtml(node.data.name_label));
if (node.data.approval_deadline_days) parts_ws.push(`Deadline: ${node.data.approval_deadline_days}d`);
if (node.data.notification_cadence && node.data.notification_cadence !== 'immediate') parts_ws.push(`Cadence: ${node.data.notification_cadence}`);
if (node.data.notification_cadence_form_field && node.data.notification_cadence === 'form_field_date') parts_ws.push(`Date field: ${node.data.notification_cadence_form_field}`);
if (node.data.notification_cadence && node.data.notification_cadence !== 'immediate') parts_ws.push(`Cadence: ${this.escapeHtml(node.data.notification_cadence)}`);
if (node.data.notification_cadence_form_field && node.data.notification_cadence === 'form_field_date') parts_ws.push(`Date field: ${this.escapeHtml(node.data.notification_cadence_form_field)}`);
if (node.data.notification_rules && node.data.notification_rules.length > 0) parts_ws.push(`Notifications: ${node.data.notification_rules.length}`);
if (node.data.trigger_conditions && node.data.trigger_conditions.conditions && node.data.trigger_conditions.conditions.length > 0) parts_ws.push('Conditional');
return parts_ws.length > 0 ?
Expand All @@ -2908,14 +2908,14 @@ class WorkflowBuilder {
if (node.data.allow_send_back) stageParts.push('Send Back target');
if (node.data.allow_reassign) stageParts.push('Reassign');
if (node.data.allow_edit_form_data) stageParts.push('Editable');
if (node.data.assignee_form_field) stageParts.push(`Dynamic: ${node.data.assignee_form_field}`);
if (node.data.assignee_form_field) stageParts.push(`Dynamic: ${this.escapeHtml(node.data.assignee_form_field)}`);
if (node.data.approval_fields && node.data.approval_fields.length > 0) stageParts.push(`Stage fields: ${node.data.approval_fields.length}`);
if (node.data.trigger_conditions && node.data.trigger_conditions.conditions && node.data.trigger_conditions.conditions.length > 0) stageParts.push('Conditional');
if (node.data.approval_groups && node.data.approval_groups.length > 0) {
const gc = node.data.approval_groups.length;
stageParts.push(`${gc} group${gc > 1 ? 's' : ''} (${node.data.approval_logic || 'all'})`);
stageParts.push(`${gc} group${gc > 1 ? 's' : ''} (${this.escapeHtml(node.data.approval_logic || 'all')})`);
}
const label = node.data.approve_label ? ` • "${node.data.approve_label}"` : '';
const label = node.data.approve_label ? ` • "${this.escapeHtml(node.data.approve_label)}"` : '';
return stageParts.length > 0 ?
`<span class="badge bg-warning">Stage ${node.data.order || '?'}</span><br><small class="text-muted">${stageParts.join(' + ')}${label}</small>` :
`<span class="badge bg-secondary">Stage ${node.data.order || '?'}</span><br><small class="text-muted">No approvers configured</small>`;
Expand All @@ -2934,22 +2934,22 @@ class WorkflowBuilder {
'not_empty': 'is not empty'
};
const op = operatorSymbols[node.data.operator] || node.data.operator;
return `If ${node.data.field} ${op} ${node.data.value || ''}`;
return `If ${this.escapeHtml(node.data.field)} ${this.escapeHtml(op)} ${this.escapeHtml(node.data.value || '')}`;
}
return 'Configure condition';
case 'action':
return node.data.action_type ? `${node.data.action_type.toUpperCase()}: ${node.data.trigger || ''}` : 'Configure action';
return node.data.action_type ? `${this.escapeHtml(node.data.action_type.toUpperCase())}: ${this.escapeHtml(node.data.trigger || '')}` : 'Configure action';
case 'email':
if (node.data.email_to && node.data.email_to_field) return `Send to: ${node.data.email_to} + field ${node.data.email_to_field}`;
if (node.data.email_to) return `Send to: ${node.data.email_to}`;
if (node.data.email_to_field) return `Send to field: ${node.data.email_to_field}`;
if (node.data.email_to && node.data.email_to_field) return `Send to: ${this.escapeHtml(node.data.email_to)} + field ${this.escapeHtml(node.data.email_to_field)}`;
if (node.data.email_to) return `Send to: ${this.escapeHtml(node.data.email_to)}`;
if (node.data.email_to_field) return `Send to field: ${this.escapeHtml(node.data.email_to_field)}`;
return 'Configure email';
case 'join':
return '<small class="text-muted">Parallel stages merge here</small>';
case 'sub_workflow':
const swParts = [];
if (node.data.sub_workflow_name) swParts.push(node.data.sub_workflow_name);
if (node.data.count_field) swParts.push(`Count: ${node.data.count_field}`);
if (node.data.sub_workflow_name) swParts.push(this.escapeHtml(node.data.sub_workflow_name));
if (node.data.count_field) swParts.push(`Count: ${this.escapeHtml(node.data.count_field)}`);
if (node.data.trigger) swParts.push(node.data.trigger === 'on_approval' ? 'After approval' : 'On submission');
if (node.data.detached) swParts.push('Detached');
if (node.data.reject_parent) swParts.push('Rejects parent');
Expand Down Expand Up @@ -3162,6 +3162,3 @@ class WorkflowBuilder {
return `M ${x1} ${y1} C ${cx1} ${y1}, ${cx2} ${y2}, ${x2} ${y2}`;
}
}

// This script is loaded before the inline admin bootstrap code.
window.WorkflowBuilder = WorkflowBuilder;
Original file line number Diff line number Diff line change
Expand Up @@ -1119,8 +1119,9 @@ <h6><i class="bi bi-info-circle"></i> Visual Builder Scope</h6>

{% block extrahead %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="{% static 'django_forms_workflows/js/workflow-builder.js' %}"></script>
<script>
<script type="module">
import { WorkflowBuilder } from "{% static 'django_forms_workflows/js/workflow-builder.js' %}";

const WORKFLOW_CONFIG = {
formId: {{ form_id }},
currentWorkflowId: {{ workflow_id }},
Expand Down
127 changes: 127 additions & 0 deletions tests_js/workflow-builder/moduleAndEscaping.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest';
import { WorkflowBuilder } from '../../django_forms_workflows/static/django_forms_workflows/js/workflow-builder.js';

function createInstance() {
return Object.create(WorkflowBuilder.prototype);
}

describe('WorkflowBuilder module wiring', () => {
it('is importable as a real ES export (not just a classic-script global)', () => {
expect(typeof WorkflowBuilder).toBe('function');
expect(typeof WorkflowBuilder.prototype.getNodeDescription).toBe('function');
expect(typeof WorkflowBuilder.prototype.buildFormProperties).toBe('function');
});
});

describe('WorkflowBuilder#getNodeDescription — escaping regression', () => {
const payload = '"><script>alert(1)</script>';

it('escapes workflow_settings name_label/notification_cadence/notification_cadence_form_field', () => {
const instance = createInstance();
const node = {
type: 'workflow_settings',
data: {
name_label: payload,
notification_cadence: 'form_field_date',
notification_cadence_form_field: payload,
},
};

const html = instance.getNodeDescription(node);

expect(html).not.toContain('<script>alert');
});

it('escapes stage assignee_form_field, approval_logic, and approve_label', () => {
const instance = createInstance();
const node = {
type: 'stage',
data: {
assignee_form_field: payload,
approval_logic: payload,
approval_groups: [{ id: 1 }],
approve_label: payload,
},
};

const html = instance.getNodeDescription(node);

expect(html).not.toContain('<script>alert');
});

it('escapes condition field/operator/value', () => {
const instance = createInstance();
const node = {
type: 'condition',
data: { field: payload, operator: payload, value: payload },
};

const html = instance.getNodeDescription(node);

expect(html).not.toContain('<script>alert');
});

it('escapes action action_type/trigger', () => {
const instance = createInstance();
const node = {
type: 'action',
data: { action_type: payload, trigger: payload },
};

const html = instance.getNodeDescription(node);

expect(html).not.toContain('<script>alert');
});

it('escapes email email_to/email_to_field', () => {
const instance = createInstance();
const node = {
type: 'email',
data: { email_to: payload, email_to_field: payload },
};

const html = instance.getNodeDescription(node);

expect(html).not.toContain('<script>alert');
});

it('escapes sub_workflow sub_workflow_name/count_field', () => {
const instance = createInstance();
const node = {
type: 'sub_workflow',
data: { sub_workflow_name: payload, count_field: payload },
};

const html = instance.getNodeDescription(node);

expect(html).not.toContain('<script>alert');
});
});

describe('WorkflowBuilder#buildFormProperties — escaping regression', () => {
it('escapes field.name, field.type, and field.prefill_source in the field list', () => {
const instance = createInstance();
const payload = '"><script>alert(1)</script>';
const node = {
id: 'form_1',
data: {
is_initial: true,
form_name: 'Test Form',
field_count: 1,
fields: [
{
label: payload,
name: payload,
type: payload,
prefill_source: payload,
},
],
has_more_fields: false,
},
};

const html = instance.buildFormProperties(node);

expect(html).not.toContain('<script>alert');
});
});
Loading