Skip to content
Open
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
7 changes: 4 additions & 3 deletions classes/models/fields/FrmFieldUrl.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,11 @@ 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' );
// skipcq: PHP-W1067 -- $this->field is always a field object by the time validate() runs; FrmFieldType's constructor just accepts array|int|object for lazy construction elsewhere.
// skipcq: PHP-W1067 -- $this->field is always a field object by the time validate() runs; FrmFieldType's constructor just accepts array|int|object for lazy construction elsewhere.
} elseif ( $this->field->required == '1' && ! $value ) { // phpcs:ignore Universal.Operators.StrictComparisons

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cannot access property $required on array|int|object


The property you are trying to access is not defined and will cause unexpected behavior when used.

$errors[ 'field' . $args['id'] ] = FrmFieldsHelper::get_error_msg( $this->field, 'blank' );
}
Expand Down
5 changes: 4 additions & 1 deletion js/formidable.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' );
Expand Down
2 changes: 1 addition & 1 deletion js/formidable.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 14 additions & 7 deletions stubs.php
Original file line number Diff line number Diff line change
Expand Up @@ -544,13 +544,20 @@ public static function route() {
*/
class WP_UnitTestCase_Base extends PHPUnit\Framework\TestCase {
/**
* FrmUnitTest::setUp() actually replaces this with a FrmUnitTestFactory, but that class
* lives under tests/, which phpstan.neon excludes from the analysis paths - PHPStan would
* report "unknown class" for a type it can never load. WP_UnitTest_Factory is the real
* base type and is declared below, so it resolves.
*
* @var WP_UnitTest_Factory
*/
* FrmUnitTest::setUp() replaces this with a FrmUnitTestFactory, which is what every
* plugin test actually sees and calls ->field/->entry/->form on, so it is typed as that
* rather than the base WP_UnitTest_Factory - DeepSource and mago both scan tests/ and
* resolve FrmUnitTestFactory fine. Redeclaring ->field/->entry/->form directly on
* WP_UnitTest_Factory instead was tried and reverted: mago requires a redeclared
* property's docblock type to match its parent exactly, and that broke the REAL
* FrmUnitTestFactory in tests/phpunit/base/frm_factory.php, which types them more
* specifically (Field_Factory|null, etc). PHPStan can't load FrmUnitTestFactory itself,
* since phpstan.neon excludes tests/ from its analysis paths - that single-line ignore
* is scoped here rather than added to phpstan.neon.
*
* @var FrmUnitTestFactory
*/
// @phpstan-ignore class.notFound
protected $factory;

/**
Expand Down
42 changes: 42 additions & 0 deletions tests/cypress/e2e/Forms/fieldsInFormBuilder.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } );
Expand Down
125 changes: 125 additions & 0 deletions tests/phpunit/fields/test_FrmFieldValidate.php
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,26 @@ 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://càphê.vn',
'invalid' => false,
),
array(
'type' => 'url',
'value' => 'https://a/b.com',
'invalid' => true,
),
);
}

Expand Down Expand Up @@ -183,6 +203,111 @@ public function test_url_value() {
$this->assertArrayHasKey( 'field' . $field->id, $errors, 'http:// passed required validation ' . print_r( $errors, 1 ) );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to an undefined method test_FrmFieldValidate::assertArrayHasKey()


The method you are trying to call is not defined, which can result in a fatal error.

}

/**
* 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' ) );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Access to an undefined property test_FrmFieldValidate::$factory


The property you are trying to access is not defined and will cause unexpected behavior when used.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Access to an undefined property WP_UnitTest_Factory::$field


The property you are trying to access is not defined and will cause unexpected behavior when used.

$this->assertNotEmpty( $field );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to an undefined method test_FrmFieldValidate::assertNotEmpty()


The method you are trying to call is not defined, which can result in a fatal error.


$should_pass = array(
'https://ernährung.ch',
'https://münchen.de',
'https://café.fr',
'https://пример.рф',
'https://例え.jp',
'https://càphê.vn',
'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 );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to an undefined method test_FrmFieldValidate::assertArrayNotHasKey()


The method you are trying to call is not defined, which can result in a fatal error.

}

/**
* 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 );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to an undefined method test_FrmFieldValidate::assertArrayHasKey()


The method you are trying to call is not defined, which can result in a fatal error.

}
}

/**
* 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' ) );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Access to an undefined property test_FrmFieldValidate::$factory


The property you are trying to access is not defined and will cause unexpected behavior when used.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Access to an undefined property WP_UnitTest_Factory::$field


The property you are trying to access is not defined and will cause unexpected behavior when used.

$this->assertNotEmpty( $field );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to an undefined method test_FrmFieldValidate::assertNotEmpty()


The method you are trying to call is not defined, which can result in a fatal error.


$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.' );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to an undefined method test_FrmFieldValidate::assertNotEmpty()


The method you are trying to call is not defined, which can result in a fatal error.


$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.' );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to an undefined method test_FrmFieldValidate::assertArrayNotHasKey()


The method you are trying to call is not defined, which can result in a fatal error.

}

/**
* 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.
*
* Deliberately carries no @covers: it reads js/formidable.js and js/formidable.min.js and never
* executes FrmFieldUrl::validate, so claiming to cover that method would credit it with
* coverage it does not provide. test_url_value() below is the test that exercises it.
*/
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 );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to an undefined method test_FrmFieldValidate::assertFileExists()


The method you are trying to call is not defined, which can result in a fatal error.


$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 );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to an undefined method test_FrmFieldValidate::assertStringNotContainsString()


The method you are trying to call is not defined, which can result in a fatal error.

}

// 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' );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to an undefined method test_FrmFieldValidate::assertSame()


The method you are trying to call is not defined, which can result in a fatal error.

$this->assertStringContainsString( $matches[0], file_get_contents( $minified ), 'js/formidable.min.js is stale. Rebuild it.' );
}

/**
* @covers FrmFieldEmail::validate
*/
Expand Down
Loading