Segurium Research Gutentor
Gutentor 4.0.6 closes REST endpoints that leaked draft content and passwords
- Plugin
- Gutentor
gutentor - Affected versions
- 4.0.5 and earlier
- Fixed in
4.0.6- Class
- Missing capability check
- Severity
- high Segurium Research assessment. No CVSS score published yet.
- CVE
- None assigned at the time of writing
- Installs
- 30,000 active
- Patch released
- Sources
Gutentor shipped 4.0.6 on 2026-08-27. The plugin is a Gutenberg block library and page builder, and about 30,000 sites run it. The release before it, 4.0.5, is the affected ceiling.
The vendor was clear about the reason. The changelog says “Security: Hardened REST API permission checks on post queries. Thanks to Ezekiel Victor for responsible disclosure.” A second line says “Security: Hardened slider data endpoint to prevent unauthorized access to draft content.”
The diff backs both lines. Four separate places in one file gained an access check or lost a parameter. The plugin’s own REST handler read post content without checking the post status, returned the plain post_password value of every post it prepared, and accepted two query parameters that let a caller filter posts by their password.
What the release fixes
The class Gutentor_Self_Api_Handler builds its own REST query layer on top of WP_Query. In 4.0.5 that layer did not repeat the access checks that WordPress core applies to posts. It read the content of non-public posts, it copied $post->post_password straight into the response, and it passed a caller-supplied has_password and post_password into the query arguments.
Version 4.0.6 adds current_user_can() checks in three code paths and deletes the two password parameters from the API surface.
- Affected versions: 4.0.5 and earlier
- Fixed in: 4.0.6
- Class: missing_capability_check
- Installs: ~30,000 sites run Gutentor
How the bug works
All of the security changes sit in one file, includes/tools/class-gutentor-self-api-handler.php. The version bump in gutentor.php is the only other change in the diff.
One thing the diff does not show: the register_rest_route() call. The route path and its permission_callback are not in the changed bytes. So this post cannot tell you from the code whether a logged-out visitor reached these handlers, or whether a low-privilege account was needed first. The fix uses current_user_can(), which returns false for a logged-out caller and for a caller without the capability. It covers both cases.
Two password parameters on the query surface
The handler registers its query parameters by hand. In 4.0.5 the list included these two:
'post_password' => array(
'type' => 'string',
'required' => false,
'sanitize_callback' => array( $this, 'sanitize_text_param' ),
'validate_callback' => array( $this, 'validate_simple_string_param' ),
),
'has_password' => array(
'type' => 'string',
'required' => false,
'sanitize_callback' => array( $this, 'sanitize_text_param' ),
'validate_callback' => array( $this, 'validate_boolean_param' ),
),
Both callbacks do what their names say. sanitize_text_param cleans a string. validate_simple_string_param checks that a string is a string. validate_boolean_param checks that a value looks like a boolean. Neither one is an access check. No callback here asks who the caller is.
The values then reach the query builder further down the same class:
/*permission*/
if ( $request->get_param( 'has_password' ) ) {
$query_args['has_password'] = $request->get_param( 'has_password' );
}
if ( $request->get_param( 'post_password' ) ) {
$query_args['post_password'] = $request->get_param( 'post_password' );
}
The comment above them reads /*permission*/. That is the only thing in 4.0.5 that treats these two as sensitive.
has_password and post_password are real WP_Query arguments. has_password set true returns only posts that carry a password. Set false, it returns only posts that do not. post_password matches the exact password string and returns the posts that use it.
That second one is an oracle. The caller does not read the password. The caller supplies a guess, and the size of the result set answers whether the guess was right. A wrong guess returns nothing. A right guess returns the post. There is no rate limit and no lockout on a WP_Query argument, so the guessing cost is one request per candidate.
has_password is the smaller problem and still a problem. It maps every protected post on the site, including protected posts a visitor would never see linked anywhere. WordPress core does not expose either argument as a public REST parameter for this reason.
4.0.6 deletes the two registrations and the two lines in the query builder. The endpoint no longer accepts the parameters at all, so there is nothing left to gate.
The password field in the response
The response builder copied the password out of the post row without any condition:
/*Password*/
$data['password'] = $post->post_password;
Every post this method prepared carried its own password in the JSON. A caller who got a protected post into a listing did not need the oracle above. The plaintext password was already in the response body, next to slug and modified_gmt.
4.0.6 wraps it:
/*Password - restricted to edit context with edit_post capability to prevent disclosure*/
if ( 'edit' === $request['context'] && current_user_can( 'edit_post', $post->ID ) ) {
$data['password'] = $post->post_password;
}
Two conditions now. The caller must ask for context=edit, and the caller must hold edit_post on that specific post. Note the second one is per post, not per site. An author who can edit their own posts still does not read another author’s password.
Post content read without a status check
The third path is the one the changelog calls the slider data endpoint. When $paged is set, the handler loads a post by ID and takes its content:
if ( $paged ) {
$post = get_post( $postId );
if ( $post ) {
$content = $post->post_content;
} else {
/*For Widgets*/
get_post() does no permission work. It returns the row for any ID, whatever the status. A draft, a pending revision, a private post and a trashed post all come back the same way. In 4.0.5 the next line took post_content from that object and the handler carried on. $postId comes from the request, so the caller chooses which post to read.
The fix inserts the check between get_post() and the content read:
$status_obj = get_post_status_object( $post->post_status );
$is_public = $status_obj && $status_obj->public;
if ( ! $is_public && ! current_user_can( 'read_post', $post->ID ) ) {
return new WP_Error(
'rest_forbidden',
__( 'Sorry, you are not allowed to do that.', 'gutentor' ),
array( 'status' => rest_authorization_required_code() )
);
}
get_post_status_object() returns the registered status object. Its public property is true for publish and false for draft, pending, private, future and trash. So public posts pass without a capability lookup, which keeps the front end working. Everything else needs read_post on that post ID.
read_post is a meta capability. WordPress maps it in map_meta_cap() against the post’s status and its author. For a draft or a pending post the map lands on the edit capability for that post type, so a random visitor fails it. For a private post it maps to read_private_posts. The error uses rest_authorization_required_code(), which returns 401 for a logged-out caller and 403 for a logged-in one.
The publish branch that checked nothing
The fourth change sits in a switch on post status inside the same class. The publish branch was empty, and the comment said so:
case 'publish':
// No additional checks needed beyond basic 'read' capability.
break;
4.0.6 replaces the comment with a capability check:
case 'publish':
if ( ! current_user_can( $post_type_obj->cap->edit_posts ) ) {
return new WP_Error(
'rest_cannot_read',
__( 'Sorry, you are not allowed to read posts in this post type.', 'gutentor' ),
array( 'status' => rest_authorization_required_code() )
);
}
break;
This is the strictest of the four. It requires edit_posts for the post type before the caller reads even published posts through this API. The vendor decided the whole handler is an editor-side tool and should not answer anonymous callers at all. The diff shows the switch and the default: branch after it, but git found no function header for the hunk, so the name of the method holding this switch is not in the changed bytes.
What an attacker gets
Reading, not writing. Nothing here runs code or changes data. The reward is content: the body of drafts, pending posts and private posts, the plaintext password of password-protected posts, and a list of which posts are protected.
On a news site or a shop that is the unpublished article and the unannounced product page. On a membership site it is the password that gates paid content, handed over in a JSON field.
Who is exposed
Sites running Gutentor 4.0.5 or any earlier release, with the plugin active. Sites on 4.0.6 are not exposed.
What widens it: no setting has to be on. The REST handler is part of the plugin core, not an optional module. The password parameters were registered whenever the API was registered.
What narrows it: the payoff depends on what the site holds. A site with no drafts, no private posts and no password-protected posts leaks nothing of value through these paths, because there is no non-public content to return. The password disclosure only matters if you use the built-in post password feature.
The role needed is the open question, and the diff does not answer it. The register_rest_route() call and its permission_callback are in a part of the file the diff does not touch, so the authentication bar for these handlers is not established here. The vendor changelog calls it “unauthorized access to draft content”, and the four added checks all use current_user_can(), which a logged-out caller fails. Treat any account level on your site, including subscriber, as able to reach it until you have updated.
What to do
Update to 4.0.6. It is on the WordPress plugin directory and installs over the old copy.
After you update, check the site for signs somebody used the old behaviour:
- Search your web server access logs for requests to
/wp-json/paths that carrypost_passwordorhas_passwordin the query string. 4.0.6 no longer accepts these two parameters, so any hit is from before the update. A burst of requests that differ only in thepost_passwordvalue is the guessing oracle described above. - Change the password on every password-protected post. In 4.0.5 the plain value sat in the API response, so treat each one as known.
- Look at drafts and private posts that hold anything you would not publish. Content from them may have been read. Nothing in this bug lets an attacker change them, so the posts themselves are intact.
The patch does four things. It removes the post_password and has_password parameters from the API registration and from the query builder. It checks the post status object and read_post before reading post_content. It gates the password response field behind context=edit plus edit_post on that post. It requires edit_posts before the handler answers for published posts. A site on 4.0.6 has all four and needs nothing else.