Skip to content

fix: keep credentials out of DSN parse failures - #10

Merged
abnegate merged 3 commits into
mainfrom
fix-parse-error-credentials
Sep 24, 2026
Merged

abnegate merged 3 commits into
mainfrom
fix-parse-error-credentials

Conversation

@abnegate

@abnegate abnegate commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

What leaked

new DSN($dsn) threw InvalidArgumentException("Unable to parse DSN: $dsn") whenever parse_url() failed. That quoted the whole DSN, user and password included. parse_url() fails on the mistakes people actually make in a DSN:

  • an empty host after the userinfo (s3://KEY:SECRET@/bucket)
  • a port out of range
  • a secret key with an unencoded /, which AWS secret keys often contain

Any caller that logged the message or let the exception escape to stderr wrote the credentials out with it. We saw this in edge: backup Jobs run inline PHP that builds a storage device from BACKUP_DSN, and an unparseable DSN printed Unable to parse DSN: s3://<access key>:<secret key>@/backups?... into the pod logs.

The stack trace leaked as well. Traces carry arguments unless zend.exception_ignore_args is on. It's off by default, and the official PHP images ship no php.ini. So the refusal's trace printed DSN->__construct('s3://KEY:SECR...'), cut to 15 bytes: the scheme, the user and the start of the password.

Fix

  • The parse failure now reads Unable to parse DSN: malformed and quotes nothing from the input. I didn't redact just the userinfo, because that means picking apart a string parse_url() has just rejected, and the query can carry secrets too. The exception type is still InvalidArgumentException.
  • The constructor's $dsn parameter is #[\SensitiveParameter], and the constructor unsets $dsn once parse_url() has read it. A trace prints a parameter's current value, so the unset covers PHP 8.0 and 8.1, which ignore the attribute. The redacted frame prints as NULL on 8.0/8.1 and Object(SensitiveParameterValue) from 8.2.

I checked all three throw sites. The scheme-required and host-required refusals already quoted nothing, and no other method echoes its input.

Tests

  • testRefusalMessageOmitsCredentials covers one case per throw site (unparseable, no scheme, no host). It asserts that neither the user nor the password appears in the message. The unparseable case failed before this change with Failed asserting that 'Unable to parse DSN: s3://AKIAKEY:SECRETKEY@/backups?region=us-east-1' does not contain "AKIAKEY".
  • testUncaughtRefusalPrintsNoCredentials turns argument capture on with the length limit lifted, then asserts that the printed exception (message plus trace) holds neither credential. With the message fix alone, it failed on DSN->__construct('s3://AKIAKEY:SECRETKEY@/backups?region=us-east-1'). Without the unset, it still fails on 8.0 and 8.1, including when PHP starts with zend.exception_ignore_args=1.

Run locally with dependencies resolved per version:

PHP Result
8.0 OK, 7 tests, 112 assertions
8.1 OK, 7 tests, 112 assertions
8.2 OK, 7 tests, 112 assertions
8.5 OK, 7 tests, 112 assertions

composer lint (Pint, psr12) passes.

After merge

This needs a 0.2.2 release. Then consumers should bump: open-runtimes/executor (0.2.*, lock at 0.2.1; see open-runtimes/executor#255), appwrite-labs/edge (lock at 0.2.1) and appwrite.

🤖 Generated with Claude Code

abnegate and others added 2 commits September 24, 2026 19:24
parse_url() rejects the mistakes people actually make in a DSN (an empty
host after the userinfo, a port out of range, a secret key with an
unencoded "/"), and the refusal quoted the whole DSN back, user and
password included. Anything that logs the message, or lets the exception
escape to stderr, wrote the credentials out with it.

Nothing of the input is kept: redacting only the userinfo would mean
picking apart a string parse_url() has just refused, and the query can
carry secrets of its own. The scheme and host refusals already quoted
nothing; the regression test pins all three.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the message clean, the refusal's stack trace still printed the DSN
argument, cut to zend.exception_string_param_max_len (15 bytes by
default): the scheme, the user and the start of the password. Traces
carry arguments unless zend.exception_ignore_args is on, and it is off
by default and in the official PHP images, which ship no php.ini.

#[\SensitiveParameter] prints the argument as
Object(SensitiveParameterValue) instead. PHP before 8.2 ignores the
attribute, so the trace test runs from 8.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@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 finding or new actionable issue was established.

Summary

The PR removes the DSN from parse-failure messages and adds protection against credentials appearing in exception traces. The latest changes clear the constructor argument after parsing and run the trace test on all supported PHP versions.

Reviews (2) · Last reviewed commit: "fix: clear the DSN from stack traces on ..."

Comment thread src/DSN/DSN.php
Comment thread tests/DSN/DSNTest.php Outdated
SensitiveParameter only takes effect from PHP 8.2, and this package
still supports 8.0 and 8.1, where the refusal's trace kept printing the
DSN argument. A trace prints a parameter's current value rather than
the one passed, so the constructor drops $dsn once parse_url() is done
with it, and the trace test now runs on every supported version.

The test no longer pins how PHP renders a redacted argument (NULL
before 8.2, Object(SensitiveParameterValue) from 8.2): it asserts that
neither the user nor the password is printed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
abnegate added a commit to open-runtimes/executor that referenced this pull request Sep 24, 2026
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 merged commit 6d0e0aa into main Sep 24, 2026
8 checks passed
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