Skip to content

fix: keep the storage connection out of refusals - #255

Merged
abnegate merged 1 commit into
mainfrom
fix-storage-dsn-trace-redaction
Sep 24, 2026
Merged

abnegate merged 1 commit into
mainfrom
fix-storage-dsn-trace-redaction

Conversation

@abnegate

@abnegate abnegate commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

What leaked

StorageFactory::getDevice() receives the connection string, access and secret keys included. A refusal leaked it in three ways:

  1. The message. It forwarded utopia-php/dsn's message, and dsn 0.2.1 and earlier quote the whole DSN when parse_url() rejects it: Unable to parse storage DSN: Unable to parse DSN: s3://KEY:SECRET@/bucket....
  2. The chained exception. previous: carried that same message, plus a DSN->__construct('s3://KEY:SECR...') frame, into anything that prints the exception.
  3. Stack traces. Every exception raised under getDevice() printed the $connection argument, cut to 15 bytes by default: the scheme and the start of the credentials. Traces carry arguments unless zend.exception_ignore_args is on. It's off in appwrite/utopia-base:php-8.5-2.0.0 (production) and phpswoole/swoole:6.2.0-php8.5-alpine (CI), and neither ships a php.ini.

The message is the most exposed. Runner\Docker copies an exception's message into the build output it returns ('content' => $throwable->getMessage(), and the Failed to store cache artifact: warnings). So a malformed OPR_EXECUTOR_CONNECTION_STORAGE or OPR_EXECUTOR_CONNECTION_BUILD_CACHE_STORAGE would put the operator's storage keys into build logs.

Fix

  • The parse refusal now reads Unable to parse storage DSN and chains nothing. What it prints no longer depends on which utopia-php/dsn version a consumer resolves. The parser's own message is fixed at the source in fix: keep credentials out of DSN parse failures utopia-php/dsn#10.
  • $connection is #[\SensitiveParameter], as utopia-php/storage already does for $secretKey.

The cost is the parser's reason (malformed, scheme is required, host is required). That detail isn't worth a leak that depends on the dependency version, and the message still names the setting to check.

Uncaught output with this PR, while dsn 0.2.1 is still locked:

Fatal error: Uncaught InvalidArgumentException: Unable to parse storage DSN in /app/src/Executor/StorageFactory.php:44
Stack trace:
#0 Command line code(1): OpenRuntimes\Executor\StorageFactory::getDevice('/', Object(SensitiveParameterValue))

Before, the same input printed the DSN twice in full (the chained message and the forwarded one) and three more times, truncated, in trace frames. Executor's own has no device refusal went from getDevice('/', 'ftp://AKIAKEY:S...') to getDevice('/', Object(SensitiveParameterValue)).

Tests

testRefusalDoesNotPrintTheConnection goes through every connection in unusableConnections() with argument capture on and the length limit lifted. It asserts that the printed exception (message, trace and any chain) shows the $root argument, which proves arguments were captured, but not the connection. On main it fails with InvalidArgumentException: Unable to parse DSN: s3://accessKey:secret@/mybucket?region=garage ... Next InvalidArgumentException: Unable to parse storage DSN: Unable to parse DSN: s3://accessKey:secret@....

Ran locally with the CI images:

Check Result
composer test:unit 36 tests, 113 assertions, OK
composer format:check pass
composer analyze no errors
composer refactor:check clean

Follow-up

Once utopia-php/dsn#10 is released, bump utopia-php/dsn (currently 0.2.*, lock at 0.2.1) so direct DSN users get the fixed message too.

🤖 Generated with Claude Code

@greptile-apps

greptile-apps Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR appears safe to merge; no outstanding blocking finding remains.

Summary

The PR redacts storage connections from exception traces and replaces credential-bearing DSN parse errors with a generic message. The revised test checks the full printed exception rather than only its trace.

Reviews (2) · Last reviewed commit: "fix: keep the storage connection out of ..."

Comment thread tests/unit/Executor/StorageFactoryTest.php Outdated
getDevice() receives the connection string with its access and secret
keys in it, and a refusal leaked it three ways:

- its message forwarded utopia-php/dsn's, which in 0.2.1 and earlier
  quotes the whole DSN back when parse_url() rejects it;
- the chained DSN exception carried that same message, and its
  DSN::__construct() frame, into anything that prints the exception;
- every exception raised beneath getDevice() printed the connection
  argument in its trace, cut to 15 bytes by default, which is the
  scheme and the start of the credentials. Traces carry arguments
  unless zend.exception_ignore_args is on, and it is off by default
  and in appwrite/utopia-base, which ships no php.ini.

The message matters most: the build path copies an exception's message
into the build output it returns, so a malformed
OPR_EXECUTOR_CONNECTION_STORAGE would put the operator's keys in build
logs. The refusal now only says the DSN could not be parsed, and chains
nothing, so what it prints no longer depends on which dsn version a
consumer resolves (utopia-php/dsn#10 fixes the parser's message at the
source). $connection is #[\SensitiveParameter], as utopia-php/storage
already does for the secret key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@abnegate
abnegate force-pushed the fix-storage-dsn-trace-redaction branch from 66c073f to a2373d6 Compare September 24, 2026 08:11
@abnegate abnegate changed the title fix: redact the storage connection from stack traces fix: keep the storage connection out of refusals Sep 24, 2026
@abnegate
abnegate merged commit 30f2aa3 into main Sep 24, 2026
7 checks passed
@abnegate
abnegate deleted the fix-storage-dsn-trace-redaction branch September 24, 2026 11:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant