Skip to content

ext/soap: report encoding errors without a bailout - #24367

Open
likemusic wants to merge 2 commits into
php:masterfrom
likemusic:soap-encoding-exceptions
Open

likemusic wants to merge 2 commits into
php:masterfrom
likemusic:soap-encoding-exceptions

Conversation

@likemusic

@likemusic likemusic commented Oct 15, 2026 •

Copy link
Copy Markdown

Follow-up to #24366, which does the same for WSDL and XML schema errors. This branch is on top of #24366, so the first commit here is #24366 itself; only the last commit is new ("ext/soap: report encoding errors without a bailout"). I will rebase once #24366 is merged or changed.

With both, #10026 is fixed for SoapClient: an encoding error in a call (for example SOAP-ERROR: Encoding: object has no 'x' property, or an invalid xsd:int in the response) no longer leaves gc_status()['protected'] at true for the rest of the request.

What changes

  • The 47 soap_errorN(E_ERROR, ...) calls in php_encoding.c use soap_encoding_error(). In a call of a SoapClient that uses exceptions it throws the same SoapFault as before (same message, fault code Client, also stored in __soap_fault) and returns. Everywhere else, i.e. SoapServer and SoapClient with exceptions => false, it is the same fatal error as before.
  • Encoders and decoders are deeply recursive and return xmlNodePtr/zval *, so instead of propagating an error status through all of them, an encoder that hits an error returns a placeholder (a BOGUS node or NULL) and the call discards the request or the response. ext/soap already works like this for exceptions thrown in the middle of encoding, e.g. the ValueError for recursive arrays in to_xml_map() or for non-backed enums.
  • Only the first error is thrown. Once it is pending, master_to_xml() and master_to_zval() skip the rest of the value. Without that, a cycle of href references that fails with Nesting level too deep would be decoded in exponential time, since nothing stops at the first failure any more.
  • After the error the request is not sent (as before, __getLastRequest() stays null), decoded values are no longer stored into objects (a typed property of a classmap class would throw a TypeError that hides the SoapFault), decoding of response headers stops (the trace of the SoapFault holds the array of output headers, writing to it trips an assertion in zend_hash), and a <Fault> whose content fails to decode does not replace __soap_fault.
  • Places where going on after the error would be unsafe now return: invalid base64, the array index overflow checks, <boolean> with an element inside, an invalid timestamp, unresolved references (they return the node itself, not NULL). get_conversion() can return NULL now, so guess_xml_convert() (which would otherwise recurse through UNKNOWN_TYPE forever) and get_array_type() handle that.

One case behaves differently, on purpose: if a callback (for example a typemap to_xml) has already thrown an exception when the encoding error happens, that exception is now the previous exception of the SoapFault. Before, the fatal error printed it as an "Uncaught ..." warning and discarded it.

The server keeps its fatal errors: SoapServer::handle() answers with a SOAP fault and ends the request, so the GC state does not matter there, and changing it would be a much larger change of handle().

Testing

Debug build of master (de7ff46337f) with #24366, unless noted otherwise.

  • ext/soap: 520 tests pass.
  • Three new tests: bugs/gh10026_encoding.phpt (one encoding and one decoding error, then 20000 cycles are collected); encoding_errors_no_bailout.phpt, which goes through 45 encoding and decoding errors with a SoapClient, including the cases above (first error wins, output headers, typed classmap property, a fault that fails to decode); and encoding_errors_nesting.phpt for Nesting level too deep on an array that contains itself once and 8 times, skipped with ASAN like the GHSA-rgrp-mwpx-f6rm tests because of the stack depth. All three fail on master without this change, and only on the lines that check the GC state; the messages are the same.
  • A corpus of 59 cases (50 errors, valid values as a control), run on patched and unpatched master in three modes, one process per run: SoapClient with exceptions, SoapClient with exceptions => false, and SoapServer:
    • with exceptions: the message, fault code, __soap_fault, __getLastRequest(), output headers and exit code are the same in all 59 cases; protected is true after the 50 errors on unpatched master and false after all of them with the patch, and 1000 cycles created afterwards are collected (0 on unpatched master). The only other difference is the previous exception described above;
    • exceptions => false and SoapServer: the output is byte-for-byte the same before and after (50 fatal errors / SOAP faults);
    • no leaks reported by the debug build, no assertions;
    • with gcov, the corpus reaches 40 of the 47 error sites. The other 7 cannot be reached as far as I can tell: the two Error calling to_xml/from_xml callback (call_user_function() only fails when the executor is inactive), the two Invalid value for type 'base64Binary' (php_base64_decode() is not strict and does not fail), the two Internal Error (the default of a switch over all type kinds), and Failed to encode list item for a list given as a string.
  • Release build with ASAN and UBSAN, USE_ZEND_ALLOC=0: the corpus in all three modes gives no report, with LeakSanitizer enabled for the SoapClient runs (leak detection off for the fatal-error modes). run-tests --asan on ext/soap: the new tests pass; the same 6 tests as on master fail with LeakSanitizer reports (bug37013, bug55323, gh16318, gh22585, r3_groupE_list_005w, r3_groupE_list_006w).

While testing I found an existing problem, independent of this PR: if a typemap from_xml callback throws while a response header is decoded, the next header is written to the output headers array that the exception trace shares, and a debug build fails with _zend_hash_str_add_or_update_i: Assertion ... failed (same on master). The fix for PHP-8.4 is #24368.

🤖 Generated with Claude Code

WSDL and XML schema errors were raised with soap_errorN(E_ERROR, ...). SoapClient turned the fatal error into a SoapFault and caught the bailout in SOAP_CLIENT_END_CODE(), but zend_bailout() had already called gc_protect(1), so the cycle collector stayed off for the rest of the request (phpGH-10026).

The WSDL and schema loaders now throw the SoapFault with soap_throw_error() and return an error to their callers, which release what they own on the way out. The message and the fault code ("WSDL") are the same as before.

SoapServer and SoapClient with the "exceptions" option disabled keep reporting these errors as fatal errors: the constructor turns the SoapFault back into the same E_ERROR once the loader has cleaned up.

Since nothing bails out of the loaders any more, the error paths that existed only to survive a bailout are gone: the zend_try in load_wsdl_ex(), requestify_string() and the copies of targetNamespace in schema_load_file(). delete_type() now also frees an unresolved "ref", which an error before schema_pass2() can leave behind.

Errors from encoding and decoding values still bail out and are not covered by this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Errors in encoding a request or decoding a response were raised with soap_errorN(E_ERROR, ...). SoapClient turned the fatal error into a SoapFault and caught the bailout in SOAP_CLIENT_END_CODE(), but zend_bailout() had already called gc_protect(1), so the cycle collector stayed off for the rest of the request (phpGH-10026).

These errors are now reported with soap_encoding_error(). In a call of a SoapClient that uses exceptions it throws the same SoapFault as before (same message, fault code "Client", also stored in __soap_fault) and returns: the encoder returns a placeholder value that its callers can handle, and the call discards the request or the response. Only the first error is thrown; once it is pending, master_to_xml() and master_to_zval() skip the rest of the value, the request is not sent, and decoded values are no longer stored, so a typed property or the shared array of output headers cannot be written to. Skipping the rest also keeps a cycle of references that fails with "Nesting level too deep" from taking exponential time.

SoapServer and SoapClient with the "exceptions" option disabled still get the same fatal error as before.

get_conversion() can now return NULL after the error, so guess_xml_convert() and get_array_type() handle that instead of recursing or dereferencing it.

One case behaves differently: if a callback (for example a typemap) has already thrown an exception when the error happens, that exception becomes the previous exception of the SoapFault. Before, the fatal error printed it as an uncaught exception warning and discarded it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant