Skip to content

Unclosed ob_start() usage #1314

@davidperezgar

Description

@davidperezgar

Add a new static analysis check to Plugin Check that detects calls to ob_start() that are not paired with a corresponding buffer-closing function (ob_get_clean(), ob_end_clean(), ob_get_flush(), ob_end_flush(), etc.) within the same logical scope.

This check is inspired by the existing ob_start check in the Plugin Team's Internal Scanner, which has been catching this issue across plugin submissions for a while.

Background

Using ob_start() without explicitly closing the buffer within the same logical flow can lead to unpredictable behaviour.

Output buffering is a valid technique, but a buffer should not be left open. WordPress is a shared environment in which core, plugins and themes run in a coordinated sequence that is not always predictable, particularly due to the way hooks work. If another component opens or closes a buffer that does not align with the plugin's own buffer, the buffer stack becomes misaligned, resulting in:

  • Headers being sent at unexpected moments.
  • Output being captured by the wrong component (or lost entirely).
  • Side effects in unrelated areas of the request lifecycle (REST responses, AJAX, redirects, etc.).
  • Hard-to-debug "white screen" or "headers already sent" errors.

Since WordPress 6.9, the recommended way to modify the full response output is the new template enhancement output buffer (introduced by Fran Torres, who proposed and led this work — see the WordPress 6.9 Frontend Performance Field Guide referenced at the end of this issue).

Check definition

  • Name (proposed): Unclosed_Ob_Start_Check
  • Category: Performance (mirrors current Plugin Check organisation)
  • Type: Static (extends Abstract_File_Check)
  • Severity: Warning
  • Scope: PHP files inside the plugin

Detection logic

The check should locate every call to ob_start() and verify that, within the same logical context (same function/method body, same closure, or same top-level file scope), there is at least one matching closing call:

ob_get_clean()
ob_end_clean()
ob_get_flush()
ob_end_flush()
ob_get_contents()  // only as part of a clean/end pair — optional, see notes

This mirrors callback_ob_start() in class-parser_calls.php of the Internal Scanner:

function callback_ob_start( $element, $key, &$foundNames ) {
    $closing_ob_start = array(
        'ob_get_clean',
        'ob_end_clean',
        'ob_get_flush',
        'ob_end_flush',
    );

    $functions_in_the_context = $this->get_functions_in_the_context(
        $element,
        $closing_ob_start,
        'after'
    );

    if ( ! empty( $functions_in_the_context ) ) {
        return false; // Paired → not an issue.
    }

    return $key; // Unpaired → flag it.
}

Consider it an issue if

  • ob_start() is left open with no corresponding ob_end_clean(), ob_end_flush() or similar closing function in the same scope.
  • ob_start() is open and not closed within the same logical flow in a way that other code can be affected by it.
  • The closing call cannot be located by static analysis (since plugin-check is fully static, this is the realistic ceiling — flag as a warning, not an error).

Do NOT consider it an issue if

  • ob_start() is open and closed within the same logical flow.
  • The buffer is intentionally closed via a hook callback registered immediately next to it, in a way that is statically obvious (best-effort heuristic).

User-facing message

When the check triggers, surface a message similar to:

Unclosed ob_start() detected

ob_start() was found without a corresponding closing call (ob_get_clean(), ob_end_clean(), ob_get_flush() or ob_end_flush()) in the same scope.

Output buffering is a valid technique, but a buffer must not be left open. WordPress is a shared environment where core, themes and other plugins may also open or close buffers, and a misaligned buffer stack causes unpredictable behaviour (headers already sent, lost output, broken redirects, etc.).

Please ensure every ob_start() is paired with a closing function within the same function scope, and that nothing (including hooks or early returns) can bypass that closing logic.

If you need to modify the full response output, use the new template enhancement output buffer available since WordPress 6.9.

Acceptance criteria

  • New class WordPress\Plugin_Check\Checker\Checks\Performance\Unclosed_Ob_Start_Check extending Abstract_File_Check.
  • Registered in Default_Check_Repository.
  • Detects unpaired ob_start() calls per scope (function, method, closure, file).
  • Reports per-occurrence results with file path, line number and the message above.
  • Unit/integration tests covering at least the cases shipped in the Internal Scanner fixture php/tests/calls/ob_start/test.php:
    • Paired in the same function → no issue.
    • ob_start() at the top of the file with no closing call → issue.
    • Multiple ob_start() in the same scope, only one closed → issue on the unpaired one.
    • ob_start() inside a closure, closed inside the same closure → no issue.
    • ob_start() inside a function, closed only conditionally (if) → flagged as warning.

This check is inspired by the work of Fran Torres (@frantorres) on the ob_start check in the Internal Scanner.

Metadata

Metadata

Assignees

No one assigned

    Labels

    ChecksAudit/test of the particular part of the plugin[Team] PluginsIssues owned by Plugins Team
    No fields configured for Enhancement.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions