From cf3f6c85538d1a405c470fe5685f46a20755dd16 Mon Sep 17 00:00:00 2001 From: Nathanael Jones Date: Wed, 19 Aug 2026 10:15:46 +0100 Subject: [PATCH 1/2] Accept internationalized domain names in Website/URL fields The host pattern in FrmFieldUrl::validate() and its twin in checkUrlField() allowed ASCII only, so valid internationalized domains were rejected. The punycode spelling of the same domain already passed, and non-ASCII in the path, query and fragment already passed, so this removes an inconsistency rather than granting anything new. The two character ranges differ by design. PHP matches UTF-8 bytes, so it uses the raw byte range; JS matches UTF-16 code units, so it needs the code unit range. Copying one literal into both would accept Cyrillic and CJK hosts server side while silently rejecting them in the browser, so a test asserts the two JS files carry the code unit form and never the PHP one. The /u modifier is deliberately not added to the PHP pattern: preg_match() returns false on invalid UTF-8, and because the result is negated that would report valid Latin-1 input as invalid. Sanitizing, escaping, storage and the scheme allowlist are untouched. The new pattern was compared byte for byte with the old one across 1408 ASCII inputs with no difference, so no ASCII url changes behaviour. Help Scout ticket 257002. Co-Authored-By: Claude Opus 5 --- classes/models/fields/FrmFieldUrl.php | 5 +- js/formidable.js | 4 +- js/formidable.min.js | 2 +- .../e2e/Forms/fieldsInFormBuilder.cy.js | 42 +++++++ .../phpunit/fields/test_FrmFieldValidate.php | 117 ++++++++++++++++++ 5 files changed, 166 insertions(+), 4 deletions(-) diff --git a/classes/models/fields/FrmFieldUrl.php b/classes/models/fields/FrmFieldUrl.php index dc09eea21d..57d7840f87 100644 --- a/classes/models/fields/FrmFieldUrl.php +++ b/classes/models/fields/FrmFieldUrl.php @@ -82,8 +82,9 @@ public function validate( $args ) { $errors = array(); - // Validate the url format - if ( $value && ! preg_match( '/^http(s)?:\/\/(?:localhost|(?:[\da-z\.-]+\.[\da-z\.-]+))/i', $value ) ) { + // Validate the url format. The host class allows \x80-\xff so internationalized domain names pass. + // Byte range by design, and no /u modifier: with /u, preg_match() returns false on invalid UTF-8. + if ( $value && ! preg_match( '/^http(s)?:\/\/(?:localhost|(?:[\da-z\x80-\xff\.-]+\.[\da-z\x80-\xff\.-]+))/i', $value ) ) { $errors[ 'field' . $args['id'] ] = FrmFieldsHelper::get_error_msg( $this->field, 'invalid' ); } elseif ( $this->field->required == '1' && ! $value ) { // phpcs:ignore Universal.Operators.StrictComparisons $errors[ 'field' . $args['id'] ] = FrmFieldsHelper::get_error_msg( $this->field, 'blank' ); diff --git a/js/formidable.js b/js/formidable.js index df4748d700..6ccc6fedac 100644 --- a/js/formidable.js +++ b/js/formidable.js @@ -541,7 +541,9 @@ function frmFrontFormJS() { let fieldID; const url = field.value; - if ( url !== '' && ! /^http(s)?:\/\/(?:localhost|(?:[\da-z\.-]+\.[\da-z\.-]+))/i.test( url ) ) { + // Keep in sync with FrmFieldUrl::validate(), but the ranges differ by design: JS matches UTF-16 + // code units, so this needs \u0080-\uFFFF where the PHP side matches raw UTF-8 bytes instead. + if ( url !== '' && ! /^http(s)?:\/\/(?:localhost|(?:[\da-z\u0080-\uFFFF\.-]+\.[\da-z\u0080-\uFFFF\.-]+))/i.test( url ) ) { fieldID = getFieldId( field, true ); if ( ! ( fieldID in errors ) ) { errors[ fieldID ] = getFieldValidationMessage( field, 'data-invmsg' ); diff --git a/js/formidable.min.js b/js/formidable.min.js index e524a764eb..88a4ea9731 100644 --- a/js/formidable.min.js +++ b/js/formidable.min.js @@ -12,7 +12,7 @@ errors,onSubmit);else if(field.type==="url")checkUrlField(field,errors);else if( "")}if(errors[fileID]===undefined)val=getFileVals(fileID);fieldID=fileID}else{if(hasClass(field,"frm_pos_none"))return errors;val=jQuery(field).val();if(val===null)val="";else if(typeof val!=="string"){tempVal=val;val="";for(i=0;i()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/i;if(""!==field.value&&pattern.test(field.value)=== false)errors[fieldID]=getFieldValidationMessage(field,"data-invmsg");if(shouldCheckConfirmField(field,onSubmit))confirmField(field,errors)}function checkPasswordField(field,errors,onSubmit){if(shouldCheckConfirmField(field,onSubmit))confirmField(field,errors)}function confirmField(field,errors){const fieldID=getFieldId(field,true);const strippedId=field.id.replace("conf_","");const strippedFieldID=fieldID.replace("conf_","");const confirmField=document.getElementById(strippedId.replace("field_","field_conf_")); if(!confirmField||errors[`conf_${strippedFieldID}`]!==undefined)return;if(fieldID!==strippedFieldID){const firstField=document.getElementById(strippedId);const {value}=firstField;const confirmValue=confirmField.value;if(value!==confirmValue)errors[`conf_${strippedFieldID}`]=getFieldValidationMessage(confirmField,"data-confmsg")}else validateField(confirmField)}function checkNumberField(field,errors){let fieldID;const number=field.value;if(number!==""&&isNaN(number/1)!==false){fieldID=getFieldId(field, diff --git a/tests/cypress/e2e/Forms/fieldsInFormBuilder.cy.js b/tests/cypress/e2e/Forms/fieldsInFormBuilder.cy.js index 5bfb99f95b..49dcb32a16 100644 --- a/tests/cypress/e2e/Forms/fieldsInFormBuilder.cy.js +++ b/tests/cypress/e2e/Forms/fieldsInFormBuilder.cy.js @@ -251,6 +251,48 @@ describe( 'Fields in the form builder', () => { cy.go( 'back' ); } ); + it( 'should accept an internationalized domain name in a Website/URL field', () => { + cy.openForm(); + + cy.log( 'Create a text field and a Website/URL field' ); + cy.get( `li[id="text"] a[title="Text"]` ).click( { force: true } ); + cy.get( `li[id="url"] a[title="Website/URL"]` ).click( { force: true } ); + + cy.log( 'Update form' ); + cy.get( '#frm_submit_side_top' ).should( 'contain', 'Update' ).click( { force: true } ); + + cy.log( "Enabling the 'Validate this form with javascript' setting" ); + cy.xpath( "//ul[@class='frm_form_nav']//a[contains(text(),'Settings')]" ).should( 'contain', 'Settings' ).click(); + cy.get( '#js_validate' ).click( { force: true } ); + cy.get( '#frm_submit_side_top' ).should( 'contain', 'Update' ).click( { force: true } ); + + cy.log( 'Click on Preview - Blank Page' ); + cy.get( '#frm-previewDrop', { timeout: 5000 } ).should( 'contain', 'Preview' ).click(); + cy.get( '.preview > .frm-dropdown-menu > :nth-child(1) > a' ).should( 'contain', 'On Blank Page' ).invoke( 'removeAttr', 'target' ).click(); + + /** + * A host with no dot must still be rejected. This proves the javascript validator really is + * running on this field, so the assertion further down cannot pass for the wrong reason. + */ + cy.log( 'A host with no dot is still rejected' ); + cy.get( '[id^="field_"]' ).filter( 'input' ).eq( 1 ).type( 'münchen' ); + cy.get( '[id^="field_"]' ).filter( 'input' ).eq( 0 ).click(); + cy.get( `[id^="frm_error_field_"]` ).should( 'exist' ); + + /** + * An accented host must be accepted. The regex runs out of the committed js/formidable.min.js, + * which is rebuilt into js/frm.min.js when the plugin is activated, so a stale minified + * artifact fails right here. + */ + cy.log( 'An internationalized domain name is accepted' ); + cy.get( '[id^="field_"]' ).filter( 'input' ).eq( 1 ).clear().type( 'https://ernährung.ch' ); + cy.get( '[id^="field_"]' ).filter( 'input' ).eq( 0 ).click(); + cy.get( `[id^="frm_error_field_"]` ).should( 'not.exist' ); + + cy.log( 'Navigate back to the formidable form page' ); + cy.go( 'back' ); + } ); + afterEach( () => { cy.log( 'Teardown - Save the form and delete it' ); cy.get( "a[aria-label='Close']", { timeout: 10000 } ).click( { force: true } ); diff --git a/tests/phpunit/fields/test_FrmFieldValidate.php b/tests/phpunit/fields/test_FrmFieldValidate.php index 5feb1a849c..2d29947f89 100644 --- a/tests/phpunit/fields/test_FrmFieldValidate.php +++ b/tests/phpunit/fields/test_FrmFieldValidate.php @@ -120,6 +120,21 @@ protected function expected_format_errors() { 'value' => 'http://', 'invalid' => false, ), + array( + 'type' => 'url', + 'value' => 'https://ernährung.ch', + 'invalid' => false, + ), + array( + 'type' => 'url', + 'value' => 'https://пример.рф', + 'invalid' => false, + ), + array( + 'type' => 'url', + 'value' => 'https://a/b.com', + 'invalid' => true, + ), ); } @@ -183,6 +198,108 @@ public function test_url_value() { $this->assertArrayHasKey( 'field' . $field->id, $errors, 'http:// passed required validation ' . print_r( $errors, 1 ) ); } + /** + * Internationalized domain names must pass validation. + * + * The host pattern in FrmFieldUrl::validate() matches UTF-8 bytes, so non-ASCII hosts are + * accepted. These are real registrable domains - .ch permits accented vowels - and the punycode + * spelling of the same domain has always passed, so accepting these adds no new capability. + * + * @covers FrmFieldUrl::validate + */ + public function test_url_idn_validation() { + $field = $this->factory->field->get_object_by_id( $this->get_field_key( 'url' ) ); + $this->assertNotEmpty( $field ); + + $should_pass = array( + 'https://ernährung.ch', + 'https://münchen.de', + 'https://café.fr', + 'https://пример.рф', + 'https://例え.jp', + 'https://ÄPFEL.DE', + 'https://xn--ernhrung-2za.ch', + 'https://example.com', + 'http://localhost', + 'https://ernährung.ch/über-uns?q=grüße#süß', + 'ernährung.ch', + ); + + foreach ( $should_pass as $url ) { + $errors = $this->check_single_value( array( $field->id => $url ) ); + $this->assertArrayNotHasKey( 'field' . $field->id, $errors, 'A valid url failed validation: ' . $url ); + } + + /** + * The last two must fail even though the class now allows non-ASCII: the pattern still + * requires a dotted host, and a hyphen placed after the byte range would turn it into the + * range 0x2E-0x80 and let path and query characters through. + */ + $should_fail = array( + 'münchen', + 'https://ä', + 'https://a/b.com', + 'https://a?b.com', + ); + + foreach ( $should_fail as $url ) { + $errors = $this->check_single_value( array( $field->id => $url ) ); + $this->assertArrayHasKey( 'field' . $field->id, $errors, 'An invalid url passed validation: ' . $url ); + } + } + + /** + * A raw Latin-1 host byte must still validate. + * + * This guards against adding the /u modifier to the host pattern. With /u, preg_match() returns + * false on invalid UTF-8, and because the result is negated the value would be reported invalid. + * + * @covers FrmFieldUrl::validate + */ + public function test_url_non_utf8_host_byte() { + $field = $this->factory->field->get_object_by_id( $this->get_field_key( 'url' ) ); + $this->assertNotEmpty( $field ); + + $url = "https://ex\xE4mple.com"; + + // Without this the assertion below would pass vacuously if the byte were stripped first. + $this->assertNotEmpty( esc_url_raw( $url ), 'The Latin-1 host byte did not survive sanitizing, so this test proves nothing.' ); + + $errors = $this->check_single_value( array( $field->id => $url ) ); + $this->assertArrayNotHasKey( 'field' . $field->id, $errors, 'A Latin-1 host byte failed validation, which suggests the /u modifier was added to the host pattern.' ); + } + + /** + * The JS copy of the host pattern must stay in step with the PHP one, and the minified artifact + * must be rebuilt whenever the source changes. + * + * There is no JS engine in this suite, so this asserts the rules on the source rather than + * running the regex: the code unit range is present, the PHP byte range was not copied across by + * mistake, the old ASCII-only class is gone, and the minified file carries the same class. + * + * @covers FrmFieldUrl::validate + */ + public function test_url_field_js_regex_parity() { + $source = FrmAppHelper::plugin_path() . '/js/formidable.js'; + $minified = FrmAppHelper::plugin_path() . '/js/formidable.min.js'; + + foreach ( array( $source, $minified ) as $file ) { + $this->assertFileExists( $file ); + + $contents = file_get_contents( $file ); + $name = basename( $file ); + + $this->assertStringContainsString( '\u0080-\uFFFF', $contents, 'The JS host pattern is missing the code unit range in ' . $name ); + $this->assertStringNotContainsString( '\x80-\xff', $contents, 'The PHP byte range was copied into ' . $name . '. JS matches UTF-16 code units, so that would reject the Cyrillic and CJK hosts the server accepts.' ); + $this->assertStringNotContainsString( '[\da-z\.-]', $contents, 'The old ASCII-only host class is still present in ' . $name ); + } + + // The host class in the source must appear verbatim in the minified artifact. + $matched = preg_match( '/\[\\\\da-z[^\]]*\]/', file_get_contents( $source ), $matches ); + $this->assertSame( 1, $matched, 'Could not find the url host class in js/formidable.js' ); + $this->assertStringContainsString( $matches[0], file_get_contents( $minified ), 'js/formidable.min.js is stale. Rebuild it so it carries the same url host class as js/formidable.js.' ); + } + /** * @covers FrmFieldEmail::validate */ From df88b2d3b71d82cc66457da8fb4cf46b2567f42d Mon Sep 17 00:00:00 2001 From: Nathanael Jones Date: Wed, 19 Aug 2026 10:27:03 +0100 Subject: [PATCH 2/2] Address PHPCS and DeepSource findings on the URL field IDN fix PHPCS: two assertion messages in test_FrmFieldValidate.php exceeded the 180 character limit (SlevomatCodingStandard.Files.LineLength). Shortened them; the detail they carried is already in the method docblocks. DeepSource JS-0117 wanted the u flag on the JS host pattern, which uses unicode escapes. Adding it required widening the class to a code point range, since under the u flag the old code unit range would no longer match astral characters that the PHP side accepts as bytes. Verified in node: the u variant is identical to the previous one on all 15 sample urls and across 640 generated ASCII cases, and an astral host still matches, so PHP and JS stay in step. DeepSource JS-R1004: four backtick strings in the new Cypress block had no interpolation. Converted to plain strings. The parity test needle and the explanatory comment were updated to match the new JS form. The PHP pattern deliberately still has no u modifier, because preg_match() returns false on malformed UTF-8 and the negated result would report valid Latin-1 input as invalid. Co-Authored-By: Claude Opus 5 --- js/formidable.js | 5 +++-- js/formidable.min.js | 2 +- tests/cypress/e2e/Forms/fieldsInFormBuilder.cy.js | 8 ++++---- tests/phpunit/fields/test_FrmFieldValidate.php | 6 +++--- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/js/formidable.js b/js/formidable.js index 6ccc6fedac..858bd2b453 100644 --- a/js/formidable.js +++ b/js/formidable.js @@ -542,8 +542,9 @@ function frmFrontFormJS() { const url = field.value; // Keep in sync with FrmFieldUrl::validate(), but the ranges differ by design: JS matches UTF-16 - // code units, so this needs \u0080-\uFFFF where the PHP side matches raw UTF-8 bytes instead. - if ( url !== '' && ! /^http(s)?:\/\/(?:localhost|(?:[\da-z\u0080-\uFFFF\.-]+\.[\da-z\u0080-\uFFFF\.-]+))/i.test( url ) ) { + // code units, so it uses the u flag and a code point range where the PHP side matches raw + // UTF-8 bytes. PHP must NOT gain /u: preg_match() returns false on malformed UTF-8. + if ( url !== '' && ! /^http(s)?:\/\/(?:localhost|(?:[\da-z\u0080-\u{10FFFF}\.-]+\.[\da-z\u0080-\u{10FFFF}\.-]+))/iu.test( url ) ) { fieldID = getFieldId( field, true ); if ( ! ( fieldID in errors ) ) { errors[ fieldID ] = getFieldValidationMessage( field, 'data-invmsg' ); diff --git a/js/formidable.min.js b/js/formidable.min.js index 88a4ea9731..c1e3044cb2 100644 --- a/js/formidable.min.js +++ b/js/formidable.min.js @@ -12,7 +12,7 @@ errors,onSubmit);else if(field.type==="url")checkUrlField(field,errors);else if( "")}if(errors[fileID]===undefined)val=getFileVals(fileID);fieldID=fileID}else{if(hasClass(field,"frm_pos_none"))return errors;val=jQuery(field).val();if(val===null)val="";else if(typeof val!=="string"){tempVal=val;val="";for(i=0;i()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/i;if(""!==field.value&&pattern.test(field.value)=== false)errors[fieldID]=getFieldValidationMessage(field,"data-invmsg");if(shouldCheckConfirmField(field,onSubmit))confirmField(field,errors)}function checkPasswordField(field,errors,onSubmit){if(shouldCheckConfirmField(field,onSubmit))confirmField(field,errors)}function confirmField(field,errors){const fieldID=getFieldId(field,true);const strippedId=field.id.replace("conf_","");const strippedFieldID=fieldID.replace("conf_","");const confirmField=document.getElementById(strippedId.replace("field_","field_conf_")); if(!confirmField||errors[`conf_${strippedFieldID}`]!==undefined)return;if(fieldID!==strippedFieldID){const firstField=document.getElementById(strippedId);const {value}=firstField;const confirmValue=confirmField.value;if(value!==confirmValue)errors[`conf_${strippedFieldID}`]=getFieldValidationMessage(confirmField,"data-confmsg")}else validateField(confirmField)}function checkNumberField(field,errors){let fieldID;const number=field.value;if(number!==""&&isNaN(number/1)!==false){fieldID=getFieldId(field, diff --git a/tests/cypress/e2e/Forms/fieldsInFormBuilder.cy.js b/tests/cypress/e2e/Forms/fieldsInFormBuilder.cy.js index 49dcb32a16..c32f1eb95e 100644 --- a/tests/cypress/e2e/Forms/fieldsInFormBuilder.cy.js +++ b/tests/cypress/e2e/Forms/fieldsInFormBuilder.cy.js @@ -255,8 +255,8 @@ describe( 'Fields in the form builder', () => { cy.openForm(); cy.log( 'Create a text field and a Website/URL field' ); - cy.get( `li[id="text"] a[title="Text"]` ).click( { force: true } ); - cy.get( `li[id="url"] a[title="Website/URL"]` ).click( { force: true } ); + cy.get( 'li[id="text"] a[title="Text"]' ).click( { force: true } ); + cy.get( 'li[id="url"] a[title="Website/URL"]' ).click( { force: true } ); cy.log( 'Update form' ); cy.get( '#frm_submit_side_top' ).should( 'contain', 'Update' ).click( { force: true } ); @@ -277,7 +277,7 @@ describe( 'Fields in the form builder', () => { cy.log( 'A host with no dot is still rejected' ); cy.get( '[id^="field_"]' ).filter( 'input' ).eq( 1 ).type( 'münchen' ); cy.get( '[id^="field_"]' ).filter( 'input' ).eq( 0 ).click(); - cy.get( `[id^="frm_error_field_"]` ).should( 'exist' ); + cy.get( '[id^="frm_error_field_"]' ).should( 'exist' ); /** * An accented host must be accepted. The regex runs out of the committed js/formidable.min.js, @@ -287,7 +287,7 @@ describe( 'Fields in the form builder', () => { cy.log( 'An internationalized domain name is accepted' ); cy.get( '[id^="field_"]' ).filter( 'input' ).eq( 1 ).clear().type( 'https://ernährung.ch' ); cy.get( '[id^="field_"]' ).filter( 'input' ).eq( 0 ).click(); - cy.get( `[id^="frm_error_field_"]` ).should( 'not.exist' ); + cy.get( '[id^="frm_error_field_"]' ).should( 'not.exist' ); cy.log( 'Navigate back to the formidable form page' ); cy.go( 'back' ); diff --git a/tests/phpunit/fields/test_FrmFieldValidate.php b/tests/phpunit/fields/test_FrmFieldValidate.php index 2d29947f89..d4e52bb837 100644 --- a/tests/phpunit/fields/test_FrmFieldValidate.php +++ b/tests/phpunit/fields/test_FrmFieldValidate.php @@ -289,15 +289,15 @@ public function test_url_field_js_regex_parity() { $contents = file_get_contents( $file ); $name = basename( $file ); - $this->assertStringContainsString( '\u0080-\uFFFF', $contents, 'The JS host pattern is missing the code unit range in ' . $name ); - $this->assertStringNotContainsString( '\x80-\xff', $contents, 'The PHP byte range was copied into ' . $name . '. JS matches UTF-16 code units, so that would reject the Cyrillic and CJK hosts the server accepts.' ); + $this->assertStringContainsString( '\u0080-\u{10FFFF}', $contents, 'The JS host pattern is missing the code unit range in ' . $name ); + $this->assertStringNotContainsString( '\x80-\xff', $contents, 'The PHP byte range was copied into ' . $name . '; that rejects Cyrillic and CJK hosts.' ); $this->assertStringNotContainsString( '[\da-z\.-]', $contents, 'The old ASCII-only host class is still present in ' . $name ); } // The host class in the source must appear verbatim in the minified artifact. $matched = preg_match( '/\[\\\\da-z[^\]]*\]/', file_get_contents( $source ), $matches ); $this->assertSame( 1, $matched, 'Could not find the url host class in js/formidable.js' ); - $this->assertStringContainsString( $matches[0], file_get_contents( $minified ), 'js/formidable.min.js is stale. Rebuild it so it carries the same url host class as js/formidable.js.' ); + $this->assertStringContainsString( $matches[0], file_get_contents( $minified ), 'js/formidable.min.js is stale. Rebuild it.' ); } /**