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
8 changes: 3 additions & 5 deletions server/src/controllers/form.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { FORM_CSS } from '../../../admin/src/form-css';
import { isSafeRedirect } from '../lib/embed';

const PLUGIN_ID = 'strapi-plugin-form-builder-cms';

Expand Down Expand Up @@ -74,6 +75,7 @@ function publicPageHtml(form: any): string {
function embedScript(): string {
return `(function () {
var PLUGIN = 'strapi-plugin-form-builder-cms';
var isSafeRedirect = ${isSafeRedirect.toString()};

function init() {
document.querySelectorAll('script[data-form-id]').forEach(function (script) {
Expand Down Expand Up @@ -228,11 +230,7 @@ function embedScript(): string {
// redirect after submit, if configured — only to an http(s) or relative URL,
// so a "javascript:"/"data:" value can never run as an open-redirect/XSS.
var redirect = form.settings && form.settings.redirectUrl;
if (redirect) {
var safe = false;
try { var ru = new URL(redirect, window.location.href); safe = ru.protocol === 'http:' || ru.protocol === 'https:'; } catch (e) { safe = false; }
if (safe) { window.location.href = redirect; return; }
}
if (redirect && isSafeRedirect(redirect, window.location.href)) { window.location.href = redirect; return; }
// otherwise show the success message — wrapped in .sfb-form + re-applying the
// theme so it stays styled (the themed <form> we just cleared is gone)
var wrap = document.createElement('div');
Expand Down
17 changes: 17 additions & 0 deletions server/src/lib/embed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Pure helpers for the embed runtime. These are injected into the served embed
* script via `.toString()`, so the browser runs the exact code covered by tests
* here — no drift between the tested function and the shipped one. Keep them
* self-contained (globals only, no imports) so the stringified source runs
* standalone in the browser.
*/

/** Is a post-submit redirect target safe to navigate to (http/https/relative only)? */
export function isSafeRedirect(url: string, base: string): boolean {
try {
const u = new URL(url, base);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch (e) {
return false;
}
}
30 changes: 30 additions & 0 deletions tests/redirect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { isSafeRedirect } from '../server/src/lib/embed';

const BASE = 'https://acme.co/contact';

describe('isSafeRedirect (post-submit redirect guard)', () => {
it('allows absolute http(s) URLs', () => {
expect(isSafeRedirect('https://acme.co/thanks', BASE)).toBe(true);
expect(isSafeRedirect('http://acme.co/thanks', BASE)).toBe(true);
});

it('allows relative paths (resolved against the current page)', () => {
expect(isSafeRedirect('/thanks', BASE)).toBe(true);
expect(isSafeRedirect('thank-you', BASE)).toBe(true);
});

it('blocks javascript: and data: URLs (XSS / open-redirect)', () => {
expect(isSafeRedirect('javascript:alert(1)', BASE)).toBe(false);
expect(isSafeRedirect('JavaScript:alert(1)', BASE)).toBe(false);
expect(isSafeRedirect('data:text/html,<script>alert(1)</script>', BASE)).toBe(false);
});

it('blocks other non-http protocols', () => {
expect(isSafeRedirect('mailto:x@y.com', BASE)).toBe(false);
expect(isSafeRedirect('ftp://acme.co/f', BASE)).toBe(false);
expect(isSafeRedirect('tel:+123', BASE)).toBe(false);
});
// note: an empty string is filtered upstream by the `redirect && …` guard in the embed,
// so it never reaches isSafeRedirect (where, against a base, it would resolve to the page).
});
Loading