REST API: Improve validation for usernames and passwords.

Also improves the slashing of user data in the REST API to avoid data loss.

Props jnylen0.
Fixes #38739.


git-svn-id: https://develop.svn.wordpress.org/trunk@39219 602fd350-edb4-49c9-b593-d223f7449a82
This commit is contained in:
Ryan McCue 2016-11-14 07:12:31 +00:00
parent 47a04c044d
commit ecb1e33d0d
3 changed files with 408 additions and 15 deletions

View File

@ -417,7 +417,16 @@ class WP_REST_Users_Controller extends WP_REST_Controller {
$ret = wpmu_validate_user_signup( $user->user_login, $user->user_email );
if ( is_wp_error( $ret['errors'] ) && ! empty( $ret['errors']->errors ) ) {
return $ret['errors'];
$error = new WP_Error( 'rest_invalid_param', __( 'Invalid user parameter(s).' ), array( 'status' => 400 ) );
foreach ( $ret['errors']->errors as $code => $messages ) {
foreach ( $messages as $message ) {
$error->add( $code, $message );
}
if ( $error_data = $error->get_error_data( $code ) ) {
$error->add_data( $error_data, $code );
}
}
return $error;
}
}
@ -429,7 +438,7 @@ class WP_REST_Users_Controller extends WP_REST_Controller {
}
$user->ID = $user_id;
$user_id = wp_update_user( $user );
$user_id = wp_update_user( wp_slash( (array) $user ) );
if ( is_wp_error( $user_id ) ) {
return $user_id;
@ -437,7 +446,7 @@ class WP_REST_Users_Controller extends WP_REST_Controller {
add_user_to_blog( get_site()->id, $user_id, '' );
} else {
$user_id = wp_insert_user( $user );
$user_id = wp_insert_user( wp_slash( (array) $user ) );
if ( is_wp_error( $user_id ) ) {
return $user_id;
@ -552,7 +561,7 @@ class WP_REST_Users_Controller extends WP_REST_Controller {
// Ensure we're operating on the same user we already checked.
$user->ID = $id;
$user_id = wp_update_user( $user );
$user_id = wp_update_user( wp_slash( (array) $user ) );
if ( is_wp_error( $user_id ) ) {
return $user_id;
@ -996,6 +1005,61 @@ class WP_REST_Users_Controller extends WP_REST_Controller {
return true;
}
/**
* Check a username for the REST API.
*
* Performs a couple of checks like edit_user() in wp-admin/includes/user.php.
*
* @since 4.7.0
*
* @param mixed $value The username submitted in the request.
* @param WP_REST_Request $request Full details about the request.
* @param string $param The parameter name.
* @return WP_Error|string The sanitized username, if valid, otherwise an error.
*/
public function check_username( $value, $request, $param ) {
$username = (string) rest_sanitize_value_from_schema( $value, $request, $param );
if ( ! validate_username( $username ) ) {
return new WP_Error( 'rest_user_invalid_username', __( 'Username contains invalid characters.' ), array( 'status' => 400 ) );
}
/** This filter is documented in wp-includes/user.php */
$illegal_logins = (array) apply_filters( 'illegal_user_logins', array() );
if ( in_array( strtolower( $username ), array_map( 'strtolower', $illegal_logins ) ) ) {
return new WP_Error( 'rest_user_invalid_username', __( 'Sorry, that username is not allowed.' ), array( 'status' => 400 ) );
}
return $username;
}
/**
* Check a user password for the REST API.
*
* Performs a couple of checks like edit_user() in wp-admin/includes/user.php.
*
* @since 4.7.0
*
* @param mixed $value The password submitted in the request.
* @param WP_REST_Request $request Full details about the request.
* @param string $param The parameter name.
* @return WP_Error|string The sanitized password, if valid, otherwise an error.
*/
public function check_user_password( $value, $request, $param ) {
$password = (string) rest_sanitize_value_from_schema( $value, $request, $param );
if ( empty( $password ) ) {
return new WP_Error( 'rest_user_invalid_password', __( 'Passwords cannot be empty.' ), array( 'status' => 400 ) );
}
if ( false !== strpos( $password, "\\" ) ) {
return new WP_Error( 'rest_user_invalid_password', __( 'Passwords cannot contain the "\\" character.' ), array( 'status' => 400 ) );
}
return $password;
}
/**
* Retrieves the user's schema, conforming to JSON Schema.
*
@ -1022,7 +1086,7 @@ class WP_REST_Users_Controller extends WP_REST_Controller {
'context' => array( 'edit' ),
'required' => true,
'arg_options' => array(
'sanitize_callback' => 'sanitize_user',
'sanitize_callback' => array( $this, 'check_username' ),
),
),
'name' => array(
@ -1066,9 +1130,6 @@ class WP_REST_Users_Controller extends WP_REST_Controller {
'description' => __( 'Description of the resource.' ),
'type' => 'string',
'context' => array( 'embed', 'view', 'edit' ),
'arg_options' => array(
'sanitize_callback' => 'wp_filter_post_kses',
),
),
'link' => array(
'description' => __( 'Author URL to the resource.' ),
@ -1119,6 +1180,9 @@ class WP_REST_Users_Controller extends WP_REST_Controller {
'type' => 'string',
'context' => array(), // Password is never displayed.
'required' => true,
'arg_options' => array(
'sanitize_callback' => array( $this, 'check_user_password' ),
),
),
'capabilities' => array(
'description' => __( 'All capabilities assigned to the resource.' ),

View File

@ -161,3 +161,24 @@ function _upload_dir_https( $uploads ) {
return $uploads;
}
// Skip `setcookie` calls in auth_cookie functions due to warning:
// Cannot modify header information - headers already sent by ...
function wp_set_auth_cookie( $user_id, $remember = false, $secure = '', $token = '' ) {
$auth_cookie = null;
$expire = null;
$expiration = null;
$user_id = null;
$scheme = null;
/** This action is documented in wp-inclues/pluggable.php */
do_action( 'set_auth_cookie', $auth_cookie, $expire, $expiration, $user_id, $scheme );
$logged_in_cookie = null;
/** This action is documented in wp-inclues/pluggable.php */
do_action( 'set_logged_in_cookie', $logged_in_cookie, $expire, $expiration, $user_id, 'logged_in' );
}
function wp_clear_auth_cookie() {
/** This action is documented in wp-inclues/pluggable.php */
do_action( 'clear_auth_cookie' );
}

View File

@ -10,11 +10,16 @@
* @group restapi
*/
class WP_Test_REST_Users_Controller extends WP_Test_REST_Controller_Testcase {
protected static $superadmin;
protected static $user;
protected static $editor;
protected static $site;
public static function wpSetUpBeforeClass( $factory ) {
self::$superadmin = $factory->user->create( array(
'role' => 'administrator',
'user_login' => 'superadmin',
) );
self::$user = $factory->user->create( array(
'role' => 'administrator',
) );
@ -25,6 +30,7 @@ class WP_Test_REST_Users_Controller extends WP_Test_REST_Controller_Testcase {
if ( is_multisite() ) {
self::$site = $factory->blog->create( array( 'domain' => 'rest.wordpress.org', 'path' => '/' ) );
update_site_option( 'site_admins', array( 'superadmin' ) );
}
}
@ -175,8 +181,8 @@ class WP_Test_REST_Users_Controller extends WP_Test_REST_Controller_Testcase {
$request = new WP_REST_Request( 'GET', '/wp/v2/users' );
$response = $this->server->dispatch( $request );
$headers = $response->get_headers();
$this->assertEquals( 50, $headers['X-WP-Total'] );
$this->assertEquals( 5, $headers['X-WP-TotalPages'] );
$this->assertEquals( 51, $headers['X-WP-Total'] );
$this->assertEquals( 6, $headers['X-WP-TotalPages'] );
$next_link = add_query_arg( array(
'page' => 2,
), rest_url( 'wp/v2/users' ) );
@ -190,7 +196,7 @@ class WP_Test_REST_Users_Controller extends WP_Test_REST_Controller_Testcase {
$request->set_param( 'page', 3 );
$response = $this->server->dispatch( $request );
$headers = $response->get_headers();
$this->assertEquals( 51, $headers['X-WP-Total'] );
$this->assertEquals( 52, $headers['X-WP-Total'] );
$this->assertEquals( 6, $headers['X-WP-TotalPages'] );
$prev_link = add_query_arg( array(
'page' => 2,
@ -205,7 +211,7 @@ class WP_Test_REST_Users_Controller extends WP_Test_REST_Controller_Testcase {
$request->set_param( 'page', 6 );
$response = $this->server->dispatch( $request );
$headers = $response->get_headers();
$this->assertEquals( 51, $headers['X-WP-Total'] );
$this->assertEquals( 52, $headers['X-WP-Total'] );
$this->assertEquals( 6, $headers['X-WP-TotalPages'] );
$prev_link = add_query_arg( array(
'page' => 5,
@ -217,7 +223,7 @@ class WP_Test_REST_Users_Controller extends WP_Test_REST_Controller_Testcase {
$request->set_param( 'page', 8 );
$response = $this->server->dispatch( $request );
$headers = $response->get_headers();
$this->assertEquals( 51, $headers['X-WP-Total'] );
$this->assertEquals( 52, $headers['X-WP-Total'] );
$this->assertEquals( 6, $headers['X-WP-TotalPages'] );
$prev_link = add_query_arg( array(
'page' => 6,
@ -393,7 +399,7 @@ class WP_Test_REST_Users_Controller extends WP_Test_REST_Controller_Testcase {
$request = new WP_REST_Request( 'GET', '/wp/v2/users' );
$request->set_param( 'offset', 1 );
$response = $this->server->dispatch( $request );
$this->assertCount( 3, $response->get_data() );
$this->assertCount( 4, $response->get_data() );
// 'offset' works with 'per_page'
$request->set_param( 'per_page', 2 );
$response = $this->server->dispatch( $request );
@ -715,6 +721,88 @@ class WP_Test_REST_Users_Controller extends WP_Test_REST_Controller_Testcase {
$this->check_add_edit_user_response( $response );
}
public function test_create_item_invalid_username() {
$this->allow_user_to_manage_multisite();
wp_set_current_user( self::$user );
$params = array(
'username' => '¯\_(ツ)_/¯',
'password' => 'testpassword',
'email' => 'test@example.com',
'name' => 'Test User',
'nickname' => 'testuser',
'slug' => 'test-user',
'roles' => array( 'editor' ),
'description' => 'New API User',
'url' => 'http://example.com',
);
// Username rules are different (more strict) for multisite; see `wpmu_validate_user_signup`
if ( is_multisite() ) {
$params['username'] = 'no-dashes-allowed';
}
$request = new WP_REST_Request( 'POST', '/wp/v2/users' );
$request->add_header( 'content-type', 'application/x-www-form-urlencoded' );
$request->set_body_params( $params );
$response = $this->server->dispatch( $request );
$this->assertErrorResponse( 'rest_invalid_param', $response, 400 );
$data = $response->get_data();
if ( is_multisite() ) {
$this->assertInternalType( 'array', $data['additional_errors'] );
$this->assertCount( 1, $data['additional_errors'] );
$error = $data['additional_errors'][0];
$this->assertEquals( 'user_name', $error['code'] );
$this->assertEquals( 'Usernames can only contain lowercase letters (a-z) and numbers.', $error['message'] );
} else {
$this->assertInternalType( 'array', $data['data']['params'] );
$errors = $data['data']['params'];
$this->assertInternalType( 'string', $errors['username'] );
$this->assertEquals( 'Username contains invalid characters.', $errors['username'] );
}
}
function get_illegal_user_logins() {
return array( 'nope' );
}
public function test_create_item_illegal_username() {
$this->allow_user_to_manage_multisite();
wp_set_current_user( self::$user );
add_filter( 'illegal_user_logins', array( $this, 'get_illegal_user_logins' ) );
$params = array(
'username' => 'nope',
'password' => 'testpassword',
'email' => 'test@example.com',
'name' => 'Test User',
'nickname' => 'testuser',
'slug' => 'test-user',
'roles' => array( 'editor' ),
'description' => 'New API User',
'url' => 'http://example.com',
);
$request = new WP_REST_Request( 'POST', '/wp/v2/users' );
$request->add_header( 'content-type', 'application/x-www-form-urlencoded' );
$request->set_body_params( $params );
$response = $this->server->dispatch( $request );
remove_filter( 'illegal_user_logins', array( $this, 'get_illegal_user_logins' ) );
$this->assertErrorResponse( 'rest_invalid_param', $response, 400 );
$data = $response->get_data();
$this->assertInternalType( 'array', $data['data']['params'] );
$errors = $data['data']['params'];
$this->assertInternalType( 'string', $errors['username'] );
$this->assertEquals( 'Sorry, that username is not allowed.', $errors['username'] );
}
public function test_create_new_network_user_on_site_does_not_add_user_to_sub_site() {
if ( ! is_multisite() ) {
$this->markTestSkipped( 'Test requires multisite.' );
@ -810,7 +898,20 @@ class WP_Test_REST_Users_Controller extends WP_Test_REST_Controller_Testcase {
wpmu_delete_user( $user_id );
$this->assertErrorResponse( 'user_name', $switched_response );
$this->assertErrorResponse( 'rest_invalid_param', $switched_response, 400 );
$data = $switched_response->get_data();
$this->assertInternalType( 'array', $data['additional_errors'] );
$this->assertCount( 2, $data['additional_errors'] );
$errors = $data['additional_errors'];
foreach ( $errors as $error ) {
// Check the code matches one we know.
$this->assertContains( $error['code'], array( 'user_name', 'user_email' ) );
if ( 'user_name' === $error['code'] ) {
$this->assertEquals( 'Sorry, that username already exists!', $error['message'] );
} else {
$this->assertEquals( 'Sorry, that email address is already used!', $error['message'] );
}
}
}
public function test_update_existing_network_user_on_sub_site_adds_user_to_site() {
@ -1305,6 +1406,213 @@ class WP_Test_REST_Users_Controller extends WP_Test_REST_Controller_Testcase {
$this->assertErrorResponse( 'rest_user_invalid_id', $response, 404 );
}
public function test_update_item_invalid_password() {
$this->allow_user_to_manage_multisite();
wp_set_current_user( self::$user );
$request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/users/%d', self::$editor ) );
$request->set_param( 'password', 'no\\backslashes\\allowed' );
$response = $this->server->dispatch( $request );
$this->assertErrorResponse( 'rest_invalid_param', $response, 400 );
$request->set_param( 'password', '' );
$response = $this->server->dispatch( $request );
$this->assertErrorResponse( 'rest_invalid_param', $response, 400 );
}
public function verify_user_roundtrip( $input = array(), $expected_output = array() ) {
if ( isset( $input['id'] ) ) {
// Existing user; don't try to create one
$user_id = $input['id'];
} else {
// Create a new user
$request = new WP_REST_Request( 'POST', '/wp/v2/users' );
foreach ( $input as $name => $value ) {
$request->set_param( $name, $value );
}
$request->set_param( 'email', 'cbg@androidsdungeon.com' );
$response = $this->server->dispatch( $request );
$this->assertEquals( 201, $response->get_status() );
$actual_output = $response->get_data();
// Compare expected API output to actual API output
$this->assertEquals( $expected_output['username'] , $actual_output['username'] );
$this->assertEquals( $expected_output['name'] , $actual_output['name'] );
$this->assertEquals( $expected_output['first_name'] , $actual_output['first_name'] );
$this->assertEquals( $expected_output['last_name'] , $actual_output['last_name'] );
$this->assertEquals( $expected_output['url'] , $actual_output['url'] );
$this->assertEquals( $expected_output['description'], $actual_output['description'] );
$this->assertEquals( $expected_output['nickname'] , $actual_output['nickname'] );
// Compare expected API output to WP internal values
$user = get_userdata( $actual_output['id'] );
$this->assertEquals( $expected_output['username'] , $user->user_login );
$this->assertEquals( $expected_output['name'] , $user->display_name );
$this->assertEquals( $expected_output['first_name'] , $user->first_name );
$this->assertEquals( $expected_output['last_name'] , $user->last_name );
$this->assertEquals( $expected_output['url'] , $user->user_url );
$this->assertEquals( $expected_output['description'], $user->description );
$this->assertEquals( $expected_output['nickname'] , $user->nickname );
$this->assertTrue( wp_check_password( addslashes( $expected_output['password'] ), $user->user_pass ) );
$user_id = $actual_output['id'];
}
// Update the user
$request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/users/%d', $user_id ) );
foreach ( $input as $name => $value ) {
if ( 'username' !== $name ) {
$request->set_param( $name, $value );
}
}
$response = $this->server->dispatch( $request );
$this->assertEquals( 200, $response->get_status() );
$actual_output = $response->get_data();
// Compare expected API output to actual API output
if ( isset( $expected_output['username'] ) ) {
$this->assertEquals( $expected_output['username'], $actual_output['username'] );
}
$this->assertEquals( $expected_output['name'] , $actual_output['name'] );
$this->assertEquals( $expected_output['first_name'] , $actual_output['first_name'] );
$this->assertEquals( $expected_output['last_name'] , $actual_output['last_name'] );
$this->assertEquals( $expected_output['url'] , $actual_output['url'] );
$this->assertEquals( $expected_output['description'], $actual_output['description'] );
$this->assertEquals( $expected_output['nickname'] , $actual_output['nickname'] );
// Compare expected API output to WP internal values
$user = get_userdata( $actual_output['id'] );
if ( isset( $expected_output['username'] ) ) {
$this->assertEquals( $expected_output['username'], $user->user_login );
}
$this->assertEquals( $expected_output['name'] , $user->display_name );
$this->assertEquals( $expected_output['first_name'] , $user->first_name );
$this->assertEquals( $expected_output['last_name'] , $user->last_name );
$this->assertEquals( $expected_output['url'] , $user->user_url );
$this->assertEquals( $expected_output['description'], $user->description );
$this->assertEquals( $expected_output['nickname'] , $user->nickname );
$this->assertTrue( wp_check_password( addslashes( $expected_output['password'] ), $user->user_pass ) );
}
public function test_user_roundtrip_as_editor() {
wp_set_current_user( self::$editor );
$this->assertEquals( ! is_multisite(), current_user_can( 'unfiltered_html' ) );
$this->verify_user_roundtrip( array(
'id' => self::$editor,
'name' => '\o/ ¯\_(ツ)_/¯',
'first_name' => '\o/ ¯\_(ツ)_/¯',
'last_name' => '\o/ ¯\_(ツ)_/¯',
'url' => '\o/ ¯\_(ツ)_/¯',
'description' => '\o/ ¯\_(ツ)_/¯',
'nickname' => '\o/ ¯\_(ツ)_/¯',
'password' => 'o/ ¯_(ツ)_/¯ \'"',
), array(
'name' => '\o/ ¯\_(ツ)_/¯',
'first_name' => '\o/ ¯\_(ツ)_/¯',
'last_name' => '\o/ ¯\_(ツ)_/¯',
'url' => 'http://o/%20¯_(ツ)_/¯',
'description' => '\o/ ¯\_(ツ)_/¯',
'nickname' => '\o/ ¯\_(ツ)_/¯',
'password' => 'o/ ¯_(ツ)_/¯ \'"',
) );
}
public function test_user_roundtrip_as_editor_html() {
wp_set_current_user( self::$editor );
if ( is_multisite() ) {
$this->assertFalse( current_user_can( 'unfiltered_html' ) );
$this->verify_user_roundtrip( array(
'id' => self::$editor,
'name' => '<div>div</div> <strong>strong</strong> <script>oh noes</script>',
'first_name' => '<div>div</div> <strong>strong</strong> <script>oh noes</script>',
'last_name' => '<div>div</div> <strong>strong</strong> <script>oh noes</script>',
'url' => '<div>div</div> <strong>strong</strong> <script>oh noes</script>',
'description' => '<div>div</div> <strong>strong</strong> <script>oh noes</script>',
'nickname' => '<div>div</div> <strong>strong</strong> <script>oh noes</script>',
'password' => '<div>div</div> <strong>strong</strong> <script>oh noes</script>',
), array(
'name' => 'div strong',
'first_name' => 'div strong',
'last_name' => 'div strong',
'url' => 'http://divdiv/div%20strongstrong/strong%20scriptoh%20noes/script',
'description' => 'div <strong>strong</strong> oh noes',
'nickname' => 'div strong',
'password' => '<div>div</div> <strong>strong</strong> <script>oh noes</script>',
) );
} else {
$this->assertTrue( current_user_can( 'unfiltered_html' ) );
$this->verify_user_roundtrip( array(
'id' => self::$editor,
'name' => '<div>div</div> <strong>strong</strong> <script>oh noes</script>',
'first_name' => '<div>div</div> <strong>strong</strong> <script>oh noes</script>',
'last_name' => '<div>div</div> <strong>strong</strong> <script>oh noes</script>',
'url' => '<div>div</div> <strong>strong</strong> <script>oh noes</script>',
'description' => '<div>div</div> <strong>strong</strong> <script>oh noes</script>',
'nickname' => '<div>div</div> <strong>strong</strong> <script>oh noes</script>',
'password' => '<div>div</div> <strong>strong</strong> <script>oh noes</script>',
), array(
'name' => 'div strong',
'first_name' => 'div strong',
'last_name' => 'div strong',
'url' => 'http://divdiv/div%20strongstrong/strong%20scriptoh%20noes/script',
'description' => 'div <strong>strong</strong> oh noes',
'nickname' => 'div strong',
'password' => '<div>div</div> <strong>strong</strong> <script>oh noes</script>',
) );
}
}
public function test_user_roundtrip_as_superadmin() {
wp_set_current_user( self::$superadmin );
$this->assertTrue( current_user_can( 'unfiltered_html' ) );
$valid_username = is_multisite() ? 'noinvalidcharshere' : 'no-invalid-chars-here';
$this->verify_user_roundtrip( array(
'username' => $valid_username,
'name' => '\\\&\\\ &amp; &invalid; < &lt; &amp;lt;',
'first_name' => '\\\&\\\ &amp; &invalid; < &lt; &amp;lt;',
'last_name' => '\\\&\\\ &amp; &invalid; < &lt; &amp;lt;',
'url' => '\\\&\\\ &amp; &invalid; < &lt; &amp;lt;',
'description' => '\\\&\\\ &amp; &invalid; < &lt; &amp;lt;',
'nickname' => '\\\&\\\ &amp; &invalid; < &lt; &amp;lt;',
'password' => '& &amp; &invalid; < &lt; &amp;lt;',
), array(
'username' => $valid_username,
'name' => '\\\&amp;\\\ &amp; &amp;invalid; &lt; &lt; &amp;lt;',
'first_name' => '\\\&amp;\\\ &amp; &amp;invalid; &lt; &lt; &amp;lt;',
'last_name' => '\\\&amp;\\\ &amp; &amp;invalid; &lt; &lt; &amp;lt;',
'url' => 'http://&amp;%20&amp;%20&amp;invalid;%20%20&lt;%20&amp;lt;',
'description' => '\\\&amp;\\\ &amp; &amp;invalid; &lt; &lt; &amp;lt;',
'nickname' => '\\\&amp;\\\ &amp; &amp;invalid; &lt; &lt; &amp;lt;',
'password' => '& &amp; &invalid; < &lt; &amp;lt;',
) );
}
public function test_user_roundtrip_as_superadmin_html() {
wp_set_current_user( self::$superadmin );
$this->assertTrue( current_user_can( 'unfiltered_html' ) );
$valid_username = is_multisite() ? 'noinvalidcharshere' : 'no-invalid-chars-here';
$this->verify_user_roundtrip( array(
'username' => $valid_username,
'name' => '<div>div</div> <strong>strong</strong> <script>oh noes</script>',
'first_name' => '<div>div</div> <strong>strong</strong> <script>oh noes</script>',
'last_name' => '<div>div</div> <strong>strong</strong> <script>oh noes</script>',
'url' => '<div>div</div> <strong>strong</strong> <script>oh noes</script>',
'description' => '<div>div</div> <strong>strong</strong> <script>oh noes</script>',
'nickname' => '<div>div</div> <strong>strong</strong> <script>oh noes</script>',
'password' => '<div>div</div> <strong>strong</strong> <script>oh noes</script>',
), array(
'username' => $valid_username,
'name' => 'div strong',
'first_name' => 'div strong',
'last_name' => 'div strong',
'url' => 'http://divdiv/div%20strongstrong/strong%20scriptoh%20noes/script',
'description' => 'div <strong>strong</strong> oh noes',
'nickname' => 'div strong',
'password' => '<div>div</div> <strong>strong</strong> <script>oh noes</script>',
) );
}
public function test_delete_item() {
$user_id = $this->factory->user->create( array( 'display_name' => 'Deleted User' ) );