Skip to content

Commit 51054cb

Browse files
fix: disable setting past date time for application passwords
1 parent b545eba commit 51054cb

7 files changed

Lines changed: 348 additions & 10 deletions

File tree

‎src/js/_enqueues/admin/application-passwords.js‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@
2121
tmplAppPassRow = wp.template( 'application-password-row' ),
2222
userId = $( '#user_id' ).val();
2323

24+
updateApplicationPasswordExpirationMinimum( $newAppPassExpiresField );
25+
$newAppPassExpiresField.on( 'focus', function() {
26+
updateApplicationPasswordExpirationMinimum( $newAppPassExpiresField );
27+
} );
28+
2429
$newAppPassButton.on( 'click', function( e ) {
2530
e.preventDefault();
2631

@@ -35,6 +40,11 @@
3540
return;
3641
}
3742

43+
updateApplicationPasswordExpirationMinimum( $newAppPassExpiresField );
44+
if ( ! $newAppPassExpiresField[0].reportValidity() ) {
45+
return;
46+
}
47+
3848
clearNotices();
3949
$newAppPassButton.prop( 'aria-disabled', true ).addClass( 'disabled' );
4050

@@ -122,6 +132,9 @@
122132
$input.val( wp.date.date( 'Y-m-d\\TH:i:s', currentExpires + 'Z' ) );
123133
}
124134
var initialExpires = $input.val();
135+
$input.on( 'focus', function() {
136+
updateApplicationPasswordExpirationMinimum( $input );
137+
} );
125138

126139
var $buttonContainer = $( '<div class="edit-expires-button-group"></div>' );
127140
var $saveBtn = $( '<button type="button" class="button button-small button-primary">' + wp.i18n.__( 'Save' ) + '</button>' );
@@ -156,7 +169,14 @@
156169
} );
157170

158171
$saveBtn.on( 'click', function() {
172+
updateApplicationPasswordExpirationMinimum( $input );
159173
var newExpires = $input.val();
174+
175+
// An existing expired value may be retained, but a newly selected time must be in the future.
176+
if ( ! ( currentExpires && newExpires === initialExpires ) && ! $input[0].reportValidity() ) {
177+
return;
178+
}
179+
160180
var expiresDate = newExpires ? wp.date.getDate( newExpires ) : null;
161181
var requestData = {
162182
expires: ( expiresDate && ! isNaN( expiresDate.getTime() ) ) ? expiresDate.toISOString() : null
@@ -289,6 +309,17 @@
289309
addNotice( errorMessage, 'error' );
290310
}
291311

312+
/**
313+
* Updates the earliest expiration allowed in an application password input.
314+
*
315+
* @since 7.2.0
316+
*
317+
* @param {jQuery} $input The expiration input.
318+
*/
319+
function updateApplicationPasswordExpirationMinimum( $input ) {
320+
$input.attr( 'min', wp.date.date( 'Y-m-d\\TH:i:s', new Date( Date.now() + 1000 ) ) );
321+
}
322+
292323
/**
293324
* Displays a message in the Application Passwords section.
294325
*

‎src/wp-admin/user-edit.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -862,7 +862,7 @@
862862

863863
<div class="form-field">
864864
<label for="new_application_password_expires"><?php _e( 'Expires on' ); ?></label>
865-
<input type="datetime-local" id="new_application_password_expires" name="new_application_password_expires" class="input ltr" step="1" aria-describedby="new_application_password_expires_desc application-passwords-timezone" />
865+
<input type="datetime-local" id="new_application_password_expires" name="new_application_password_expires" class="input ltr" step="1" min="<?php echo esc_attr( wp_date( 'Y-m-d\TH:i:s', time() + 1 ) ); ?>" aria-describedby="new_application_password_expires_desc application-passwords-timezone" />
866866
<p class="description" id="new_application_password_expires_desc"><?php _e( 'Optional. Set an expiration date and time for this password.' ); ?></p>
867867
</div>
868868

‎src/wp-includes/class-wp-application-passwords.php‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,15 @@ public static function is_in_use() {
6161
* @since 5.6.0
6262
* @since 5.7.0 Returns WP_Error if application name already exists.
6363
* @since 6.8.0 The hashed password value now uses wp_fast_hash() instead of phpass.
64+
* @since 7.2.0 Returns WP_Error if the expiration is not in the future.
6465
*
6566
* @param int $user_id User ID.
6667
* @param array $args {
6768
* Arguments used to create the application password.
6869
*
69-
* @type string $name The name of the application password.
70-
* @type string $app_id A UUID provided by the application to uniquely identify it.
70+
* @type string $name The name of the application password.
71+
* @type string $app_id A UUID provided by the application to uniquely identify it.
72+
* @type int|null $expires Optional. Unix timestamp of when the password expires. Default null.
7173
* }
7274
* @return array|WP_Error {
7375
* Application password details, or a WP_Error instance if an error occurs.
@@ -95,6 +97,17 @@ public static function create_new_application_password( $user_id, $args = array(
9597
return new WP_Error( 'application_password_empty_name', __( 'An application name is required to create an application password.' ), array( 'status' => 400 ) );
9698
}
9799

100+
$created = time();
101+
$expires = isset( $args['expires'] ) ? (int) $args['expires'] : null;
102+
103+
if ( null !== $expires && $expires <= $created ) {
104+
return new WP_Error(
105+
'application_password_invalid_expiration',
106+
__( 'The expiration date and time must be in the future.' ),
107+
array( 'status' => 400 )
108+
);
109+
}
110+
98111
$new_password = wp_generate_password( static::PW_LENGTH, false );
99112
$hashed_password = self::hash_password( $new_password );
100113

@@ -103,10 +116,10 @@ public static function create_new_application_password( $user_id, $args = array(
103116
'app_id' => empty( $args['app_id'] ) ? '' : $args['app_id'],
104117
'name' => $args['name'],
105118
'password' => $hashed_password,
106-
'created' => time(),
119+
'created' => $created,
107120
'last_used' => null,
108121
'last_ip' => null,
109-
'expires' => isset( $args['expires'] ) ? (int) $args['expires'] : null,
122+
'expires' => $expires,
110123
);
111124

112125
$passwords = static::get_user_application_passwords( $user_id );

‎tests/e2e/specs/profile/applications-passwords.test.js‎

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,178 @@ test.describe( 'Manage applications passwords', () => {
331331
} );
332332
} );
333333

334+
test.describe( 'Application password creation expiry validation', () => {
335+
test.use( { timezoneId: 'Australia/Melbourne' } );
336+
337+
test( 'should restrict new expiry times in the site timezone and refresh the minimum', async ( {
338+
page,
339+
admin,
340+
} ) => {
341+
await page.clock.setFixedTime( new Date( '2028-07-15T10:00:00Z' ) );
342+
await admin.visitAdminPage( '/profile.php' );
343+
await page.evaluate( () => {
344+
const settings = window.wp.date.getSettings();
345+
window.wp.date.setSettings( {
346+
...settings,
347+
timezone: { ...settings.timezone, string: '', offset: 5.5 },
348+
} );
349+
} );
350+
351+
const input = page.getByLabel( 'Expires on' );
352+
await input.focus();
353+
await expect( input ).toHaveAttribute( 'min', '2028-07-15T15:30:01' );
354+
await input.fill( '2028-07-15T15:29:59' );
355+
expect(
356+
await input.evaluate(
357+
( element ) => element.validity.rangeUnderflow
358+
)
359+
).toBe( true );
360+
await input.fill( '2028-07-15T15:30:01' );
361+
expect(
362+
await input.evaluate( ( element ) => element.checkValidity() )
363+
).toBe( true );
364+
365+
await page.clock.setFixedTime( new Date( '2028-07-15T10:00:30Z' ) );
366+
await input.blur();
367+
await input.focus();
368+
await expect( input ).toHaveAttribute( 'min', '2028-07-15T15:30:31' );
369+
370+
// A time that was valid when the picker opened may have passed before submission.
371+
await page.clock.setFixedTime( new Date( '2028-07-15T10:01:00Z' ) );
372+
await page
373+
.getByLabel( 'New Application Password Name' )
374+
.fill( 'Not created' );
375+
let submitted = false;
376+
await page.route( '**/application-passwords?*', async ( route ) => {
377+
submitted = true;
378+
await route.abort();
379+
} );
380+
await page
381+
.getByRole( 'button', {
382+
name: 'Add Application Password',
383+
exact: true,
384+
} )
385+
.click();
386+
await expect( input ).toHaveAttribute( 'min', '2028-07-15T15:31:01' );
387+
expect(
388+
await input.evaluate(
389+
( element ) => element.validity.rangeUnderflow
390+
)
391+
).toBe( true );
392+
expect( submitted ).toBe( false );
393+
await expect(
394+
page.getByRole( 'button', {
395+
name: 'Add Application Password',
396+
exact: true,
397+
} )
398+
).not.toHaveClass( /disabled/ );
399+
await input.fill( '' );
400+
expect(
401+
await input.evaluate( ( element ) => element.checkValidity() )
402+
).toBe( true );
403+
} );
404+
} );
405+
406+
test.describe( 'Application password table expiry validation', () => {
407+
test.use( { timezoneId: 'Australia/Melbourne' } );
408+
409+
test( 'should block past selections in the inline editor while allowing expiry removal', async ( {
410+
page,
411+
admin,
412+
} ) => {
413+
await page.clock.setFixedTime( new Date( '2028-07-15T10:00:00Z' ) );
414+
await admin.visitAdminPage( '/profile.php' );
415+
const uuid = '11111111-1111-4111-8111-111111111111';
416+
await page.evaluate( ( fixtureUuid ) => {
417+
const settings = window.wp.date.getSettings();
418+
window.wp.date.setSettings( {
419+
...settings,
420+
timezone: { ...settings.timezone, string: '', offset: 5.5 },
421+
} );
422+
window.jQuery( '#application-passwords-section tbody' ).append(
423+
window.wp.template( 'application-password-row' )( {
424+
uuid: fixtureUuid,
425+
app_id: '',
426+
name: 'Browser-only expiry fixture',
427+
created: '2028-07-15T09:00:00',
428+
last_used: null,
429+
last_ip: null,
430+
expires: '2028-07-16T10:00:00',
431+
} )
432+
);
433+
window.jQuery( '.application-passwords-list-table-wrapper' ).show();
434+
}, uuid );
435+
436+
const submitted = [];
437+
await page.route(
438+
`**/application-passwords/${ uuid }?*`,
439+
async ( route ) => {
440+
submitted.push( route.request().postDataJSON() );
441+
await route.fulfill( {
442+
status: 400,
443+
contentType: 'application/json',
444+
body: JSON.stringify( {
445+
message: 'Save intercepted for testing.',
446+
} ),
447+
} );
448+
}
449+
);
450+
const row = page.locator( `tr[data-uuid="${ uuid }"]` );
451+
await row.locator( '.edit-expires' ).click();
452+
const input = row.getByLabel( 'Expiration date and time', {
453+
exact: true,
454+
} );
455+
const save = row.getByRole( 'button', { name: 'Save', exact: true } );
456+
457+
await expect( input ).toHaveAttribute( 'min', '2028-07-15T15:30:01' );
458+
await input.fill( '2028-07-14T15:30' );
459+
expect(
460+
await input.evaluate(
461+
( element ) => element.validity.rangeUnderflow
462+
)
463+
).toBe( true );
464+
await save.click();
465+
expect( submitted ).toHaveLength( 0 );
466+
await expect( save ).toBeEnabled();
467+
468+
await input.fill( '2028-07-15T15:30:01' );
469+
await page.clock.setFixedTime( new Date( '2028-07-15T10:01:00Z' ) );
470+
await save.click();
471+
await expect( input ).toHaveAttribute( 'min', '2028-07-15T15:31:01' );
472+
expect( submitted ).toHaveLength( 0 );
473+
await page.clock.setFixedTime( new Date( '2028-07-15T10:01:30Z' ) );
474+
await input.blur();
475+
await input.focus();
476+
await expect( input ).toHaveAttribute( 'min', '2028-07-15T15:31:31' );
477+
478+
await input.fill( '2028-07-15T15:32' );
479+
await save.click();
480+
await expect( page.getByRole( 'alert' ) ).toContainText(
481+
'Save intercepted for testing.'
482+
);
483+
expect( submitted ).toEqual( [
484+
{ expires: '2028-07-15T10:02:00.000Z' },
485+
] );
486+
await input.fill( '' );
487+
await save.click();
488+
await expect.poll( () => submitted.length ).toBe( 2 );
489+
expect( submitted[ 1 ] ).toEqual( { expires: null } );
490+
491+
// Opening an already expired password must not silently change its stored expiry.
492+
await row
493+
.getByRole( 'button', { name: 'Cancel', exact: true } )
494+
.click();
495+
await row.evaluate( ( element ) => {
496+
window.jQuery( element ).data( 'expires', '2028-07-14T10:00:00' );
497+
} );
498+
await row.locator( '.edit-expires' ).click();
499+
await expect( input ).toHaveValue( '2028-07-14T15:30' );
500+
await save.click();
501+
await expect.poll( () => submitted.length ).toBe( 3 );
502+
expect( submitted[ 2 ] ).toEqual( { expires: '2028-07-14T10:00:00Z' } );
503+
} );
504+
} );
505+
334506
class ApplicationPasswords {
335507
constructor( { requestUtils, page, admin } ) {
336508
this.requestUtils = requestUtils;

‎tests/phpunit/tests/auth.php‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1774,12 +1774,14 @@ public function test_authenticate_application_password_expired() {
17741774
add_filter( 'application_password_is_api_request', '__return_true' );
17751775
add_filter( 'wp_is_application_passwords_available', '__return_true' );
17761776

1777-
list( $password ) = WP_Application_Passwords::create_new_application_password(
1777+
list( $password, $item ) = WP_Application_Passwords::create_new_application_password(
17781778
self::$user_id,
1779-
array(
1780-
'name' => 'phpunit',
1781-
'expires' => time() - DAY_IN_SECONDS,
1782-
)
1779+
array( 'name' => 'phpunit' )
1780+
);
1781+
WP_Application_Passwords::update_application_password(
1782+
self::$user_id,
1783+
$item['uuid'],
1784+
array( 'expires' => time() - DAY_IN_SECONDS )
17831785
);
17841786

17851787
$error = wp_authenticate_application_password( null, self::$_user->user_login, $password );

‎tests/phpunit/tests/rest-api/application-passwords.php‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ public function test_create_new_application_password( array $args, array $names
9999
array_keys( $new_item )
100100
);
101101
$this->assertSame( $args['name'], $new_item['name'] );
102+
$this->assertSame( $args['expires'] ?? null, $new_item['expires'] );
102103
}
103104

104105
public function data_create_new_application_password() {
@@ -116,6 +117,51 @@ public function data_create_new_application_password() {
116117
'expires' => time() + DAY_IN_SECONDS,
117118
),
118119
),
120+
'should create new password with null expiration' => array(
121+
'args' => array(
122+
'name' => 'test_null_expire',
123+
'expires' => null,
124+
),
125+
),
126+
);
127+
}
128+
129+
/**
130+
* @ticket 53995
131+
* @dataProvider data_create_new_application_password_with_non_future_expiration
132+
*
133+
* @param int $offset Expiration offset from the current time, in seconds.
134+
*/
135+
public function test_create_new_application_password_with_non_future_expiration( $offset ) {
136+
WP_Application_Passwords::create_new_application_password( self::$user_id, array( 'name' => 'Existing' ) );
137+
$passwords = WP_Application_Passwords::get_user_application_passwords( self::$user_id );
138+
$created_count = did_action( 'wp_create_application_password' );
139+
140+
$result = WP_Application_Passwords::create_new_application_password(
141+
self::$user_id,
142+
array(
143+
'name' => 'Expired',
144+
'expires' => time() + $offset,
145+
)
146+
);
147+
148+
$this->assertWPError( $result );
149+
$this->assertSame( 'application_password_invalid_expiration', $result->get_error_code() );
150+
$this->assertSame( array( 'status' => 400 ), $result->get_error_data() );
151+
$this->assertSame( $passwords, WP_Application_Passwords::get_user_application_passwords( self::$user_id ) );
152+
$this->assertSame( $created_count, did_action( 'wp_create_application_password' ) );
153+
}
154+
155+
/**
156+
* Data provider for rejecting an expiration that is not in the future.
157+
*
158+
* @return array[]
159+
*/
160+
public function data_create_new_application_password_with_non_future_expiration() {
161+
return array(
162+
'past day' => array( -DAY_IN_SECONDS ),
163+
'past second' => array( -1 ),
164+
'current second' => array( 0 ),
119165
);
120166
}
121167

@@ -196,6 +242,10 @@ public function data_update_application_password() {
196242
'update' => array( 'expires' => time() + DAY_IN_SECONDS ),
197243
'existing' => array( 'name' => 'Test' ),
198244
),
245+
'should update expires to the past' => array(
246+
'update' => array( 'expires' => time() - DAY_IN_SECONDS ),
247+
'existing' => array( 'name' => 'Test' ),
248+
),
199249
'should clear expires' => array(
200250
'update' => array( 'expires' => null ),
201251
'existing' => array(

0 commit comments

Comments
 (0)