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
36 changes: 28 additions & 8 deletions inc/class-credits.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,11 @@ public function register_settings(): void {
'general',
'credits_custom_html',
[
'title' => __('Custom Footer HTML', 'ultimate-multisite'),
'desc' => __('HTML allowed. Use any text or link you prefer.', 'ultimate-multisite'),
'type' => 'textarea',
'allow_html' => true,
'default' => [$this, 'get_default_custom_credit_html'],
'title' => __('Custom Footer HTML', 'ultimate-multisite'),
'desc' => __('HTML allowed. Use any text or link you prefer.', 'ultimate-multisite'),
'type' => 'textarea',
'allow_html' => true,
'default' => [$this, 'get_default_custom_credit_html'],
'value' => function () {
return $this->normalize_custom_credit_html(
wu_get_setting('credits_custom_html', $this->get_default_custom_credit_html())
Expand All @@ -111,8 +111,8 @@ public function register_settings(): void {
wu_get_setting('credits_custom_html', $this->get_default_custom_credit_html())
);
},
'placeholder' => __('Powered by <a href="https://example.com">Your Company</a>', 'ultimate-multisite'),
'require' => [
'placeholder' => __('Powered by <a href="https://example.com">Your Company</a>', 'ultimate-multisite'),
'require' => [
'credits_enable' => 1,
'credits_type' => 'html',
],
Expand All @@ -139,8 +139,28 @@ protected function normalize_custom_credit_html($html): string {

/**
* Returns the default custom credit HTML.
*
* Public so the array callable `[$this, 'get_default_custom_credit_html']`
* registered as a field default (see register_settings() above) resolves
* via `is_callable()` when checked from outside this class — for example
* in `WP_Ultimo\Settings::save_settings()`, which lives in a different
* class scope.
*
* `is_callable([$instance, 'protected_method'])` returns `false` outside
* the declaring class scope. When that happens, `Settings::save_settings()`
* never invokes the callable, the literal array `[$instance, 'method-name']`
* survives as the field default, and it is later persisted/used as the
* field value. For a textarea field this leaks into
* `Field::validate_textarea_field()` → `addslashes()`, fatally:
* "Uncaught TypeError: addslashes(): Argument #1 ($string) must be of
* type string, array given".
*
* The Setup Wizard symptom is twofold: the data-state JSON the Vue form
* is initialised from contains the unresolved callable array, so the form
* renders with no settings fields; clicking Continue still POSTs and
* triggers the fatal on save.
*/
protected function get_default_custom_credit_html(): string {
public function get_default_custom_credit_html(): string {
$name = (string) get_network_option(null, 'site_name');
$name = $name ?: __('this network', 'ultimate-multisite');
$url = is_multisite() ? get_site_url(get_main_site_id()) : network_home_url('/');
Expand Down
44 changes: 1 addition & 43 deletions inc/ui/class-field.php
Original file line number Diff line number Diff line change
Expand Up @@ -492,62 +492,20 @@ protected function validate_number_field($value) {
/**
* Cleans the value submitted via a textarea or wp_editor field for database insertion.
*
* Defensive against non-string inputs: arrays, objects (including Closures and
* stdClass), and other scalar types can reach this validator when:
* - A form posts `name="field[]"` syntax that PHP parses into an array.
* - A previously-stored corrupted value (array / "[object Object]" / Closure)
* is read back from the database as the existing fallback in
* Settings::save_settings() for fields not in the current form step.
* - A filter on `wu_settings_fields_sanitization_rules` routes a non-textarea
* field type here.
*
* Without this guard, `addslashes()` / `stripslashes()` raise a TypeError on
* PHP 8+ ("Argument #1 ($string) must be of type string, array given"),
* which fatals the Settings save / Setup Wizard step.
*
* @since 2.0.0
*
* @param mixed $value Value of the settings being represented by this field.
* @param string $value Value of the settings being represented by this field.
* @return string
*/
protected function validate_textarea_field($value) {

$value = $this->coerce_textarea_value($value);

if ($this->allow_html) {
return stripslashes(wp_filter_post_kses(addslashes($value)));
}

return wp_strip_all_tags(stripslashes($value));
}

/**
* Coerces a textarea field value to a safe string.
*
* Returns an empty string for arrays/objects/null and casts scalars
* to string. This prevents TypeError fatals downstream in
* addslashes()/stripslashes() and matches the historical behaviour of
* silently dropping malformed values rather than propagating corruption.
*
* @since 2.5.2
*
* @param mixed $value Raw value to coerce.
* @return string
*/
private function coerce_textarea_value($value): string {

if (is_string($value)) {
return $value;
}

if (is_null($value) || is_array($value) || is_object($value)) {
return '';
}

// Booleans, ints, floats — cast to their string form.
return (string) $value;
}

/**
* Echo HTML attributes for the field.
*
Expand Down
43 changes: 43 additions & 0 deletions tests/WP_Ultimo/Credits_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -276,4 +276,47 @@ public function test_init_registers_hooks(): void {
$this->assertNotFalse(has_action('wp_footer', [$this->credits, 'render_frontend_footer']));
$this->assertNotFalse(has_action('login_footer', [$this->credits, 'render_frontend_footer']));
}

/**
* Regression: the method registered as the `credits_custom_html` field default
* callable MUST be publicly callable from outside the Credits class.
*
* `WP_Ultimo\Settings::save_settings()` checks `is_callable($field_default)`
* from a different class scope. If this method becomes protected/private the
* check returns `false`, the unresolved array `[$instance, 'method-name']`
* survives as the field default, breaks the Setup Wizard data-state JSON,
* and produces a `TypeError: addslashes(): Argument #1 must be of type string,
* array given` fatal in `Field::validate_textarea_field()`.
*/
public function test_get_default_custom_credit_html_is_callable_externally(): void {

$callable = [$this->credits, 'get_default_custom_credit_html'];

$this->assertTrue(
is_callable($callable),
'Credits::get_default_custom_credit_html must be publicly callable so the field-default array callable resolves in Settings::save_settings().'
);

// Reflection confirms the visibility contract.
$reflection = new \ReflectionMethod($this->credits, 'get_default_custom_credit_html');
$this->assertTrue(
$reflection->isPublic(),
'Credits::get_default_custom_credit_html must be declared public.'
);
}

/**
* Regression: invoking the field-default callable returns a non-empty string.
*
* Mirrors the call path in `Settings::save_settings()` and confirms the value
* that lands in the field is a string suitable for textarea validation.
*/
public function test_get_default_custom_credit_html_returns_non_empty_string(): void {

$result = call_user_func([$this->credits, 'get_default_custom_credit_html']);

$this->assertIsString($result);
$this->assertNotEmpty($result);
$this->assertStringContainsString('Powered by', $result);
}
}
93 changes: 0 additions & 93 deletions tests/WP_Ultimo/UI/Field_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@
'title' => 'Test',
]);

$json = json_encode($field);

Check warning on line 225 in tests/WP_Ultimo/UI/Field_Test.php

View workflow job for this annotation

GitHub Actions / Code Quality Checks

json_encode() is discouraged. Use wp_json_encode() instead.

$this->assertIsString($json);
$this->assertJson($json);
Expand Down Expand Up @@ -265,97 +265,4 @@
// Accessing title should fallback to name
$this->assertEquals('Field Name', $field->title);
}

/**
* Textarea sanitization must accept a plain string and round-trip safely.
*/
public function test_textarea_sanitization_string_value(): void {
$field = new Field('test_field', [
'type' => 'textarea',
'default' => '',
]);

$field->set_value("Line one\nLine two");

$this->assertSame("Line one\nLine two", $field->get_value());
}

/**
* Regression: textarea sanitization must NOT fatal when given an array value.
*
* This reproduces the historical TypeError raised by addslashes() / stripslashes()
* on PHP 8+ when a stored DB value (or a malformed POST payload) reaches the
* textarea validator as an array. Expected behaviour is to coerce to an empty
* string rather than fatal the Settings save / Setup Wizard step.
*/
public function test_textarea_sanitization_coerces_array_to_empty_string(): void {
$field = new Field('test_field', [
'type' => 'textarea',
'allow_html' => false,
'default' => '',
]);

$field->set_value(['unexpected', 'array', 'value']);

$this->assertSame('', $field->get_value());
}

/**
* Regression: wp_editor (allow_html=true) must also tolerate array input.
*/
public function test_wp_editor_sanitization_coerces_array_to_empty_string(): void {
$field = new Field('test_field', [
'type' => 'wp_editor',
'allow_html' => true,
'default' => '',
]);

$field->set_value(['<p>not</p>', '<p>a</p>', '<p>string</p>']);

$this->assertSame('', $field->get_value());
}

/**
* Object values (Closures, stdClass) must coerce to empty string, not fatal.
*/
public function test_textarea_sanitization_coerces_object_to_empty_string(): void {
$field = new Field('test_field', [
'type' => 'textarea',
'default' => '',
]);

$field->set_value((object) ['foo' => 'bar']);

$this->assertSame('', $field->get_value());
}

/**
* Null values must coerce to empty string.
*/
public function test_textarea_sanitization_coerces_null_to_empty_string(): void {
$field = new Field('test_field', [
'type' => 'textarea',
'default' => '',
]);

$field->set_value(null);

$this->assertSame('', $field->get_value());
}

/**
* Scalar non-strings (int, float, bool) must cast to their string form.
*/
public function test_textarea_sanitization_casts_scalar_to_string(): void {
$field = new Field('test_field', [
'type' => 'textarea',
'default' => '',
]);

$field->set_value(42);
$this->assertSame('42', $field->get_value());

$field->set_value(3.14);
$this->assertSame('3.14', $field->get_value());
}
}
Loading