Skip to content

Removed the admin secret key toggle so admin GET actions are always protected - #1256

Merged
fballiano merged 6 commits into
mainfrom
claude/issue-1242-stability-3k3x8f
Aug 11, 2026
Merged

Removed the admin secret key toggle so admin GET actions are always protected#1256
fballiano merged 6 commits into
mainfrom
claude/issue-1242-stability-3k3x8f

Conversation

@fballiano

@fballiano fballiano commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Closes #1242.

The admin/security/use_form_key toggle didn't trade one CSRF protection for another: turning it off dropped automatic GET protection and replaced it with a hand-maintained list of _setForcedFormKeyActions() / getUrlSecure() pairs that had already drifted out of sync (#1241). Admin urls now always carry the per-action secret key, so POST is always covered by the form key, GET by the secret key, and the whole opt-in list is deleted.

  • preDispatch() validates the form key on POST and the secret key on everything else, with no config branch. $_publicActions remains the only exemption.
  • Deleted _setForcedFormKeyActions(), _checkIsForcedFormKeyAction() and all ~56 controller call sites; most were the controller's entire preDispatch() override.
  • Deleted getUrlSecure() / getUrlSecureParams() and rewired their call sites to plain getUrl(), which now always mints the key. form_key never appears in a url again.
  • Removed the system.xml field, its backend model, the config.xml default and isEnabledSecurityKeyUrl(). useSecretKey() now only reflects the per-url _nosecret opt-out (login flow, RSS), and the login-time renewSecretUrls() / redirect-policy checks lost their config branches.
  • New Mage_Core upgrade script (2.1.0 → 2.1.1) deletes any stored admin/security/use_form_key rows, so installs that had the toggle off pick up the always-on behavior explicitly. Those installs lose bookmarkable admin deep links, which is the accepted cost from the issue.
  • Browser tests (AdminReindexDialogTest, AdminQueryStringUrlsTest, AdminStoreSwitcherTest, AdminRelatedProductsGridTest) used to disable the toggle to deep-link into admin; they now mint the real secret key from the logged-in page's window.FORM_KEY via a shared adminPathWithSecretKey() helper, so they exercise the same validation real navigation does. AdminUrlFormKeyTest was rewritten for the always-on behavior.
  • Also fixes the two findings PHPStan 2.2.8 (from the composer update on main) reports on files this PR doesn't otherwise touch, so the check is green here. Note this changes the public constant Mage_CatalogIndex_Model_Data_Abstract::LINK_GET_PARENTS from 1 to 2: it previously shared LINK_GET_CHILDREN's value, so any third-party caller passing it got children and now gets parents.

Third-party compatibility: the deleted methods (_setForcedFormKeyActions(), _checkIsForcedFormKeyAction(), getUrlSecure(), getUrlSecureParams(), turnOnSecretKey(), turnOffSecretKey(), isEnabledSecurityKeyUrl()) are removed without deprecation shims. Modules that call them (the turnOffSecretKey() pattern is common in custom RSS/export endpoints inherited from Magento 1) fail with "Call to undefined method" and must migrate to $_publicActions / _nosecret.

$_publicActions: the one remaining exemption, documented

With the toggle gone, $_publicActions is the only way an admin GET action skips secret key validation. Public actions are still behind the admin session cookie and ACL; "public" only means "no per-action key required". The rule for using it: the action must be render/read-only (the secret key defends against CSRF, which only matters for state-changing requests), and it exists for links that are generated outside any admin session, where a valid key is impossible in principle (the key derives from the recipient session's form key, unknowable at generation time). Every keyless (_nosecret) link generator is paired with the public action that receives it:

Controller Public actions Keyless link generator it serves
Mage_Adminhtml_Sales_OrderController view, index New-orders admin RSS feed (Mage_Rss_Block_Order_New) links each order and the grid
Mage_Adminhtml_Catalog_ProductController edit Low-stock admin RSS feed (Mage_Rss_Block_Catalog_NotifyStock) links each product's edit page
Mage_Adminhtml_Catalog_Product_ReviewController edit New-reviews admin RSS feed (Mage_Rss_Block_Catalog_Review) links each review's edit page
Maho_FeedManager_Adminhtml_Feedmanager_FeedController edit Failure emails and admin inbox notifications from Maho_FeedManager_Model_Notifier
Maho_Ai_Adminhtml_AiController taskStatus Read-only JSON status poll from admin JS

The FeedManager entry is a bug fix found while auditing this pairing: its notifier already sent _nosecret links to feedmanager_feed/edit, but the controller never declared the action public, so those links bounced to the dashboard on every install that validated keys (the default before this PR too). While there, the "Save & Generate" session flag gained a 60-second expiry with clear-on-read, so a lost redirect can no longer start a stale generation on a later visit to the edit page. The login-flow _nosecret urls (login, forgot/reset password) need no entry: validation only runs for logged-in sessions.

The OAuth authorize confirm/reject actions also left the public list: the in-page flow carries the secret key in the url path, so it keeps working. The one degraded path is a Reject link in a stale logged-out tab after logging in elsewhere, which now redirects to the dashboard instead of rejecting the token; accepted as the cost of closing the CSRF hole.

Note

Developed with the help of AI.
As part of our commitment to GenAI transparency, we flag pull requests produced with AI assistance alongside human work. As with every change in Maho, a maintainer reviews and validates it before merge, we never merge purely AI-generated changes. See the GenAI transparency section for details.

claude added 4 commits August 11, 2026 16:29
…rotected

The admin/security/use_form_key toggle didn't trade one CSRF protection for
another: turning it off dropped automatic GET protection and replaced it with
a hand-maintained list of _setForcedFormKeyActions() / getUrlSecure() pairs
that had already drifted out of sync (#1241). Admin urls now always carry the
per-action secret key, so POST is always covered by the form key and GET by
the secret key, and the whole opt-in list becomes dead code:

- preDispatch() validates the form key on POST and the secret key otherwise,
  with no config branch
- deleted _setForcedFormKeyActions(), _checkIsForcedFormKeyAction() and all
  ~56 controller call sites, most of which were the whole preDispatch override
- deleted getUrlSecure()/getUrlSecureParams() and rewired their call sites to
  plain getUrl(), which now always mints the key
- removed the system.xml field, its backend model, the config default and the
  isEnabledSecurityKeyUrl() helper; useSecretKey() now only reflects the
  per-url _nosecret opt-out (login flow, RSS)
- upgrade script deletes stored admin/security/use_form_key rows
- browser tests mint the secret key from the page's FORM_KEY via the new
  adminPathWithSecretKey() helper instead of disabling the protection

Closes #1242
- LINK_GET_PARENTS shared LINK_GET_CHILDREN's value, making the parents
  branch of fetchLinkInformation() dead code; gave it its own value. The
  method's only live caller passes LINK_GET_CHILDREN, so behavior is
  unchanged.
- $m[3] is always set in the cms/block directive callback (PHP only
  truncates trailing unmatched captures), so dropped the ?? on it.
…llback

PHPStan's match-shape analysis proves the block_id capture of whichever
alternation matched is non-empty, so the === '' branch could never run.
Maho_FeedManager_Model_Notifier builds feedmanager_feed/edit urls with
_nosecret for failure emails and admin inbox notifications, but the
controller never declared the action public, so the links bounced to the
dashboard whenever secret keys were validated. Declaring the render-only
edit action public completes the same pairing the RSS admin feeds use.
@fballiano
fballiano marked this pull request as ready for review August 11, 2026 17:03
@fballiano fballiano added this to the 26.9.0 milestone Aug 11, 2026
@fballiano fballiano added the ✨ ai-assisted Developed with the help of AI label Aug 11, 2026
…posed

Review follow-ups to the secret-key work:

- Admin OAuth authorize confirm/reject were public GET actions that grant or
  reject a token from an attacker-controllable oauth_token, a CSRF hole. The
  admin button block now builds those urls through adminhtml/url so they carry
  the per-action secret key, and only index/simple stay public.
- FeedManager made feedmanager_feed/edit public, but the edit page auto-starts
  generation from a generate=1 url param, so a cross-site GET could trigger it.
  The trigger now rides a one-shot session flag armed only by the key/POST
  protected save and generate actions.
- RSS basic-auth polls re-run admin login every request; renewSecretUrls() ran
  unconditionally and flushed the admin menu cache on every poll. It is skipped
  again for keyless (RSS) logins.
- getSecretKey() takes an optional form key so the browser-test helper reuses
  the real derivation instead of a copy.
- Upgrade script uses deleteConfigData() with an exact path and declares
  strict_types; _getRequestUri() gains its native return type; trailing blank
  lines left by the removed preDispatch overrides are cleaned up.
…in review

- The save-and-generate session flag now stores the feed id with a
  timestamp; the edit page consumes it clear-on-read and ignores flags
  older than 60 seconds, so a lost redirect can no longer start a stale
  generation on a later, unrelated visit.
- login() no longer builds the keyed redirect url that request-less
  (RSS basic-auth) logins always discard, and its menu-flush guard uses
  useSecretKey() instead of respelling the same predicate.
- preDispatch() tracks one key-validity flag instead of two mutually
  exclusive ones.
- getSecretKey() parses the request path only when controller or action
  is missing.
- The four browser tests share a single adminLoginAndVisit() helper, and
  the secret key url segment comes from SECRET_KEY_PARAM_NAME instead of
  a hard-coded 'key'.
@fballiano
fballiano merged commit f7da320 into main Aug 11, 2026
25 of 26 checks passed
@fballiano
fballiano deleted the claude/issue-1242-stability-3k3x8f branch August 11, 2026 19:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ ai-assisted Developed with the help of AI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Drop the admin secret key toggle and always protect admin GET actions with it

2 participants