Repository navigation
1.9.79: GET /users/{id}/preferences serves no integration secret - #270
Conversation
GET /users/{id}/preferences answered SELECT * FROM 202_users_pref, so any
key that could read a user's preferences (users:read, the CLIs' p202 user
prefs get and user:prefs:get, and the answer the update commands print)
was handed the account's integration secrets as stored: the ClickBank,
JVZoo, Zaxaa and IPQS keys, the Slack webhook, the RevContent id and
secret, and the Landing Page Optimizer's lpo_site_key and
lpo_bridge_config (whose ctx_key is a signing key). The docs said the
secrets were never served.
Prosper202\User\ServedPreferences decides what leaves the server: the
columns on SERVED, an allow-list of those that hold no credential, as
stored; every other column, the nine secrets among them, as
{"stored": true|false}. A column on neither list is answered as a secret,
and ServedPreferencesTest fails until every column of
UserTables::usersPref() is on SERVED or SECRET, so a column added later
cannot be served by default. UsersController keeps the raw row private
(preferencesRow()); getPreferences() and the PUT answer go through
serve(), and accountCurrency() reads the raw row.
A body GET answered now goes back to PUT whole, as CLAUDE.md #4 asks:
before, PUT refused it for user_id and every other column it does not
write. Those keys are read-only, accepted with the value GET gave them
and refused with any other. A secret's {"stored": ...} sent back as GET
gave it keeps the secret; any other object is refused naming the field
({"stored": false} does not remove one; "" does), and a secret is still
set from a string. A body of nothing but unchanged keys is a 422, as the
CRUD base answers it.
The staged-apply redactor is the second line behind this: its needles
gain revcontent_user (revcontent_user_id was classified there as not a
credential), and PreferenceSecretCoverageTest now reads its allow-list
from ServedPreferences::SERVED, so the tree has one classification of the
table.
The Go CLI (p202 user prefs get/update) and the PHP CLI
(user:prefs:get/update) print a stored state as (stored) or
(not stored) in a table; --json, --ndjson and --quiet print the answer as
the API gave it. Help, docs/openapi.yaml (StoredSecret), the users and
system API docs, docs/cli.md, docs/cli-agent.md and both changelogs say
so.
Sweep of 202_users_pref reads that leave the server: GET/PUT
/users/{id}/preferences (fixed here, and the staged-apply result of the
PUT); GET /system/integrations (already secret_stored booleans); the
other v3 reads (AdministrationController's keyword, retention, MaxMind and
deletion settings, TrackingBaseLookup's domain) name no secret column; the
legacy v1/v2 APIs read no preferences; the MCP catalogue has no user tool
and the hosted connector's keys have no users area; sync, export and
import carry no users or preferences. MysqlUserRepository::getPreferences()
(SELECT *) has no caller outside tests. Pages are out of scope: Account >
API integrations writes secrets into its form (another item), and many
pages read account 1's Slack webhook to post to Slack.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
1.9.79 gained #262. The only conflict was 202-config/changelogs.json, where both sides added 1.9.79 lines: 1.9.79's lines are kept and this branch's follow them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
|
@copilot resolve the merge conflicts on this branch. |
…s-not-served # Conflicts: # 202-config/changelogs.json Co-authored-by: tracking202 <306382+tracking202@users.noreply.github.com>
Merged |
…start IPN branch
GET /users/{id}/preferences now serves every integration secret as its
stored state (#270), and the PayKickstart IPN keys are one more:
paykickstart_ipn_secret_keys is on ServedPreferences::SECRET, answered
{"stored": true}, and the count this branch served in its place
(paykickstart_ipn_keys_stored, PayKickstartKeys::withoutKeys()) is gone.
GET /system/integrations keys_stored still says how many. The
PreferenceSecretsAreNotServedIntegrationTest round trip stores the keys
with the other secrets.
Conflicts: changelogs (1.9.79's lines, then this branch's), the
preferences docs and the OpenAPI schemas (1.9.79's, with the keys added
as a StoredSecret), and the controller (1.9.79's).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
The paragraphs the breakdown change added to the help of report breakeven, losers, winners and crosstab lengthened the long descriptions p202 search ranks on, and after #270 that moved "set the api url" from config set-url to system integrations (TestSearchReadsEditVerbsAsUpdate: the two scored 0.0328 and 0.0323 on 1.9.79, 0.0325 each with the paragraphs). One line each says the same, and the two score as on 1.9.79 again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…JVZoo branch #268 now dates a notification's row by the provider's own time, held to the click for a sale and to the sale for a refund, and brings 1.9.79's #270 (ServedPreferences: GET preferences serves each secret as {"stored": ...}), #280, #281 and #282. Conflicts: both changelogs keep the base's lines with the JVZoo line after them; the OpenAPI preferences update takes #270's oneOf shape, with the JVZoo key's rule in its string branch; the eval cases keep #268's admin-005 notes and add admin-006 after it. jvzoo_ipn_secret_key is already on ServedPreferences::SECRET, and JVZoo adds no other column. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…79's #270, #280, #281, #282) into the ClickBank branch Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr # Conflicts: # 202-config/changelogs.json # changelogs.txt
Backlog: 9KK7
What was wrong
I confirmed this by reading the code and reproduced it live on an instance built from
origin/1.9.79.GET /users/{id}/preferencesansweredSELECT * FROM 202_users_pref. Any key that could read preferences therefore got every integration secret as stored:lpo_site_keyandlpo_bridge_config(itsctx_keyis a signing key).Every one of those values showed up in the response bytes. The same applied to the CLIs (
p202 user prefs get,user:prefs:get) and to the answer the update commands print. Meanwhiledocumentation/api/15-system.mdsaid the secrets are never served.A second finding: a body that GET answered could not be sent back. The brief says it "must still round-trip", but on
origin/1.9.79PUT refused it with 422 foruser_idand every other column it does not write. It now round-trips.What changed
Prosper202\User\ServedPreferences(new) decides what leaves the server:SERVEDis an allow-list of the 55 columns that hold no credential. These are served as stored.{"stored": true|false}. That includes the 9 onSECRET, and any column on neither list, so it fails closed."0"counts as stored.I picked the object shape over a sibling
*_storedkey. The field keeps its name, and a reader that expects a string fails loudly instead of quietly reading""(CLAUDE.md #28). The shape is documented asStoredSecretindocs/openapi.yaml.UsersControllerpreferencesRow()).getPreferences()and the PUT answer go throughserve().accountCurrency()reads the raw row.PUT accepts a GET body (CLAUDE.md #4).
ServedPreferences::withoutEchoes()sorts the keys first:{"stored": …}sent back as GET gave it keeps the secret. Any other object is refused, naming the field. So{"stored": false}does not clear a secret (""does), and{"stored": true}is never stored as a value.cb_verifiedreset still happens only whencb_keychanges.No valid fields to update), the same answer as the CRUD base.Staged-change redactor (second line of defence).
StagedChangesController::SECRET_KEY_SUBSTRINGSgainsrevcontent_user.revcontent_user_idhad been classified there as not a credential, while this item treats it as a secret.PreferenceSecretCoverageTestnow takes its allow-list fromServedPreferences::SERVED, so the tree has one classification of the table.CLIs
p202 user prefs get/updateand PHPuser:prefs:get/updateprintcb_key: (stored)or(not stored).--json,--ndjsonand--quietprint the API's answer unchanged.Longtext movedTestSearchReadsEditVerbsAsUpdate's "set the api url" offconfig set-url, because the search index readsLong.Docs:
documentation/api/14-users.md,documentation/api/15-system.md,docs/openapi.yaml,docs/cli.md,docs/cli-agent.md, the route comment inapi/v3/index.php, and a "Fixed: Security." line in both changelogs (no exploit steps).Sweep: reads of
202_users_prefthat leave the serverGET /users/{id}/preferences, and the answer ofPUT(also the result a staged apply of that PUT returns)GET /system/integrationssecret_storedbooleansAdministrationController: keyword, retention, MaxMind, deletion settings;TrackingBaseLookup: domain)api/v1,api/v2,api/legacygo-clicatalogue has no user tool; the hosted connector's keys have nousersarea (AiConnect)p202 user prefs updatesync/export/import(GoportableEntities, serverSyncEngine)MysqlUserRepository::getPreferences()(SELECT *)Tests
tests/User/ServedPreferencesTest(unit and structural):UserTables::usersPref()is on exactly one list, and the lists name only real columns;SERVED;serve()and throughUsersController::getPreferences()(mocked mysqli);tests/User/PreferenceSecretsAreNotServedIntegrationTest(@group integration, scratch DB; also run alone on a freshly created DB):cb_verified, and answers the body sent;cb_keyis stored and resets verification, with the others kept;tests/Cli/Commands/UserPreferencesCommandsTestand GoTestUserPrefsShowAStoredSecretAsItsState/TestUserPrefsJSONKeepsTheStoredStateTheAPIGave/TestWithStoredStatesLeavesEverythingElseAlone.Planted removals. Each was backed up to a fresh
mktempfile and checked withcmp. Each counts as caught only when the named test fails withFAILURES!naming it, or--- FAIL:for Go. All 12 were caught:getPreferencesserves the raw rowServedPreferencesTest::testGetPreferencesServesTheRowThroughIt,PreferenceSecretsAreNotServedIntegrationTest::testGetServesNoSecretAndSaysWhichAreStoredserve()serves everythingtestASecretIsServedOnlyAsWhetherOneIsStoredcb_keymoved toSERVEDtestEveryColumnOfTheTableIsServedOrSecretSERVEDcolumn dropped from the liststestEveryColumnOfTheTableIsServedOrSecretusersPref()and classified nowheretestEveryColumnOfTheTableIsServedOrSecrettestWhatComesBackChangedIsRefusedByName, integrationtestAStoredStateThatIsNotTheRecordsWritesNothingtestAGetBodySentBackChangesNothingItHoldsUnchanged, integrationtestPutWithTheBodyGetAnsweredKeepsEverySecretupdatePreferencesskipswithoutEchoestestPutWithTheBodyGetAnsweredKeepsEverySecretrevcontent_userneedle removedPreferenceSecretCoverageTest::testEveryCredentialColumnOfUsersPrefIsRedactedOnApplyTestUserPrefsShowAStoredSecretAsItsStateUserPreferencesCommandsTest::testAStoredSecretIsShownAsItsState""reads as storedtestStoredMeansAValueIsThere, integrationtestGetServesNoSecretAndSaysWhichAreStoredVerification
.claude/skills/p202-verify/scripts/verify.sh --since origin/1.9.79 --changed(after the commit, with a scratch database for the schema tier):The unit tier ran 6095 tests, with 5 skipped: the documented DB-singleton skips, plus the cron test that skips when the checkout has a
202-config.php.An earlier
--changedrun on the working tree had one unit failure:VacuousAssertionRuleTest, where PHPStan hitNo space left on devicewriting/tmp/phpstanbecause the shared disk was nearly full. That is environmental. The test passed when run alone, and the run above is clean.Integration suites, against scratch DBs
p202_9kk7_*:PreferenceSecretsAreNotServedIntegrationTest(5/5) andPreferencesWriteIntegrationTest(5/5).Live pass: an instance from this branch (php 8.3
-S, MariaDB 10.11), driven by a script:PUT /users/1/preferences. The other 4 (RevContent id and secret,lpo_site_key,lpo_bridge_config) were stored by SQL, because nothing in the tree writes them: the RevContent columns are dead, and LPO pairing goes through the hosted service.GET /users/1/preferences,p202 user prefs get 1(table and--json),p202 user prefs updateandbin/p202 user:prefs:get 1(table and--json): none of the 9 secret values appears in any of the bytes (grepped one by one). Each secret shows as{"stored":true}or(stored).cb_verifiedare unchanged in the DB.{"cb_key":{"stored":false}}, a stringlpo_site_keyanduser_id: 2: 422 naming those three, nothing written.cb_key: stored in the DB,cb_verifiedreset, the other secrets kept, and the answer does not contain the new key.GET /system/integrations: no secret bytes.Not run
@group instancesuites were not run.UserManagementRulesInstanceTestonly asserts a refusal on this route.For the reviewer
paykickstart_ipn_secret_keysand, ingetPreferences(),PayKickstartKeys::withoutKeys($row), which serves a count aspaykickstart_ipn_keys_stored. 1.9.79 does not have that column yet. Whichever PR merges second must:ServedPreferences::SECRET;ServedPreferencesTestfails until it is classified;getPreferences(), which will also conflict textually;paykickstart_ipn_keys_storedwould be a served key PUT refuses as unknown unless it is listed as read-only.revcontent_user_idis now a secret, as the brief names it. The staged-apply redactor's earlier comment called it a non-credential account id.PUTof a whole GET body is still refused for staging, because its secret-named keys trip the staging guard. That is unchanged, and it fails in the safe direction.Found outside this item
cli/Commands/UserPreferencesUpdateCommand.phpdescribes--user-daily-emailas "on/off". The server takes""or00-23, soonis refused. Left alone per "no other CLI change".202_users_pref.revcontent_user_idandrevcontent_user_secrethave no reader or writer in the tree (dead columns since RevContent was removed).Generated by Claude Code