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..858bd2b453 100644 --- a/js/formidable.js +++ b/js/formidable.js @@ -541,7 +541,10 @@ 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 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 e524a764eb..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 5bfb99f95b..c32f1eb95e 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..d4e52bb837 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-\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.' ); + } + /** * @covers FrmFieldEmail::validate */