[GH-ISSUE #1578] [Bug] Initial browser storage seeds overwrite application state after navigation #529

Closed
opened 2026-10-15 18:25:55 +00:00 by bsakaguchi · 1 comment
Owner

Originally created by @dakjdakd on GitHub (Oct 10, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/1578

Background

Browser scenarios accept initial cookies, localStorage, and sessionStorage so a capture can begin from a defined application state. The storage schema explicitly describes these values as initial state. That contract should let an application consume the supplied state and then evolve it through ordinary user actions and navigation.

Observed behavior

capture-browser-scenario restores the declared initial localStorage and sessionStorage values every time the selected page loads a new document. An application can update both stores successfully, but a subsequent same-origin navigation puts the original values back before the application's first script executes.

This changes the behavior being investigated. The scenario capture can complete successfully, report every step as completed, and produce complete, equality-eligible Evidence even though the observer has undone the application's state transition.

The reduced case uses a local HTTP page and ordinary button handlers. Both stores start with state=initial. Clicking Update writes state=updated. Navigating to another path on the same origin should preserve updated; REA captures initial instead. An ordinary Chrome/Playwright control using the same page and navigation preserves updated.

Area: Browser scenario capture / runtime observation.
Interface: Public one-shot CLI.
Earliest observable failure: Capture or observation, during the next document's initialization.
Target: A source-owned local HTTP application; no account, credentials, external service, or provider engine is required.

Reproduction

Run this from a checkout with locked dependencies and the compiled runtime available:

npm ci
npm run build:cached

Save the following as storage-site.mjs. It serves the same document at every path so that a navigation changes the document without changing the origin or the application logic.

import { createServer } from "node:http";

createServer((_request, response) => {
  response.setHeader("content-type", "text/html");
  response.end(`<!doctype html><html><body>
    <pre id="state"></pre>
    <button id="update">Update</button>
    <button id="delete">Delete</button>
    <button id="empty">Empty</button>
    <script>
      function render() {
        document.querySelector('#state').textContent = JSON.stringify({
          local: localStorage.getItem('state'),
          session: sessionStorage.getItem('state')
        });
      }
      for (const action of ['update', 'delete', 'empty']) {
        document.getElementById(action).onclick = () => {
          for (const storage of [localStorage, sessionStorage]) {
            if (action === 'delete') storage.removeItem('state');
            else storage.setItem('state', action === 'empty' ? '' : 'updated');
          }
          render();
        };
      }
      render();
    </script>
  </body></html>`);
}).listen(8765, "127.0.0.1", () => {
  console.log("Storage fixture: http://127.0.0.1:8765");
});

Start it in a separate terminal:

node storage-site.mjs

Save this as storage-scenario.json. The executable shown is a standard Windows Chrome installation path; select the absolute executable path for the browser installed on your host.

{
  "browser": {
    "mode": "launch",
    "executable_path": "C:/Program Files/Google/Chrome/Application/chrome.exe",
    "headless": true
  },
  "start_url": { "url": "http://127.0.0.1:8765/" },
  "storage": {
    "local_storage": [{
      "origin": "http://127.0.0.1:8765",
      "entries": [{
        "name": "state",
        "value": { "source": "literal", "value": "initial" }
      }]
    }],
    "session_storage": [{
      "origin": "http://127.0.0.1:8765",
      "entries": [{
        "name": "state",
        "value": { "source": "literal", "value": "initial" }
      }]
    }]
  },
  "actions": [
    {
      "step_id": "update",
      "action": "click",
      "locator": { "kind": "css", "selector": "#update" },
      "timeout_ms": 2000
    },
    {
      "step_id": "navigate",
      "action": "goto",
      "destination": { "url": "http://127.0.0.1:8765/next" },
      "wait_until": "load",
      "timeout_ms": 2000
    }
  ],
  "capture": {
    "after_each_step": ["dom", "storage"],
    "at_end": ["dom", "storage"]
  }
}

Run the public CLI:

node scripts/rea.mjs capture-browser-scenario storage-scenario.json --json

Inspect normalized_result.steps, particularly the captured DOM and storage artifacts for scenario_start, update, and navigate.

Relevant observed evidence

The executed baseline produced these values in both stores:

Step Actual Expected
scenario_start initial initial
update updated updated
navigate initial updated

The storage value hashes independently agree with the page's own DOM observation:

initial: ac1b5c0961a7269b6a053ee64276ed0e20a7f48aefb9f67519539d23aaf10149
updated: 27eb5e51506c911f6fc4bb345c0d9db6f60415fceab7c18e1e9b862637415777

The CLI exited successfully, reported terminated-owned-process cleanup, and marked the capture complete and equality-eligible. The control browser retained updated after navigation.

Expected behavior

The schema describes these values as initial storage. They should be available before the first application script reads the store, then become application-owned state. Later changes, deliberate removal, and valid empty strings must survive navigation. A declared origin first reached later in the scenario should still receive its initial values on its first visit.

Initialization lifetime should belong to the capture that requested the seed. Starting a separate scenario can establish a new initial state, but navigation within one running scenario is part of the application's behavior under observation. This distinction also makes the result useful for differential analysis: two applications that handle persistence differently should produce different observations. If the observer restores the same input on every document, it can erase that difference and make a broken persistence flow look equivalent to a correct one. The supplied state must therefore stop acting as a recurring assignment once its storage area has been initialized.

To reproduce the deletion variant, change the first locator to #delete: the next document should see null, not a restored seed. With #empty, it should see "". Checking whether a key currently exists cannot establish whether initialization has already happened; a removed key is a valid application outcome.

Impact and scope

Login/logout, shopping carts, preferences, and other persistence workflows can be misrepresented. For example, an application that removes a seeded session token during logout can have that value restored on its next navigation. This report establishes an observer-induced state reset; it does not claim a particular third-party application's authentication vulnerability.

Cookie initialization already runs once through the context. The defect concerns the repeated web-storage writes. It occurs before Evidence projection, so changing the displayed artifacts would leave the altered application execution intact.

Source-derived cause and fix direction

installStorageSeeds in src/browser/PlaywrightScenarioSession.ts installs a persistent page.addInitScript containing unconditional localStorage.setItem and sessionStorage.setItem loops. Playwright runs that script for subsequent documents and child frames. There is no initialization owner outside the page to distinguish a first storage access from a later application mutation.

A fix is prepared that initializes each actual Chromium storage area once, before application scripts, with provider-owned bookkeeping. Using the browser's storage key matters for attached browsers: a cross-site iframe and a later top-level visit to the same origin can address different partitioned stores. Consuming an origin-wide seed in the iframe would incorrectly leave the later top-level area uninitialized. The fix also preserves the selected tab's session-storage ownership and keeps initialization markers out of captured application storage. The corresponding implementation is available in https://github.com/morluto/rea/pull/1579.

Environment and additional context

The original CLI/control reproduction ran on Windows with Chrome 154.0.8037.98, REA 6.3.0 at 9ffc6210, and Node 24.11.1. The affected session source is identical at the current contribution base, c19e15a2. The prepared fix was verified with pinned Node 24.18.0, npm 11.16.0, and Playwright 1.58.2, including real launch and external-connect workflows.

  • Searched open and closed issues and PRs for a matching storage-seed lifecycle report.
  • Removed credentials, sensitive target data, and private filesystem paths.
Originally created by @dakjdakd on GitHub (Oct 10, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/1578 ## Background Browser scenarios accept initial cookies, `localStorage`, and `sessionStorage` so a capture can begin from a defined application state. The storage schema explicitly describes these values as initial state. That contract should let an application consume the supplied state and then evolve it through ordinary user actions and navigation. ## Observed behavior `capture-browser-scenario` restores the declared initial `localStorage` and `sessionStorage` values every time the selected page loads a new document. An application can update both stores successfully, but a subsequent same-origin navigation puts the original values back before the application's first script executes. This changes the behavior being investigated. The scenario capture can complete successfully, report every step as completed, and produce complete, equality-eligible Evidence even though the observer has undone the application's state transition. The reduced case uses a local HTTP page and ordinary button handlers. Both stores start with `state=initial`. Clicking **Update** writes `state=updated`. Navigating to another path on the same origin should preserve `updated`; REA captures `initial` instead. An ordinary Chrome/Playwright control using the same page and navigation preserves `updated`. **Area:** Browser scenario capture / runtime observation. **Interface:** Public one-shot CLI. **Earliest observable failure:** Capture or observation, during the next document's initialization. **Target:** A source-owned local HTTP application; no account, credentials, external service, or provider engine is required. ## Reproduction Run this from a checkout with locked dependencies and the compiled runtime available: ```sh npm ci npm run build:cached ``` Save the following as `storage-site.mjs`. It serves the same document at every path so that a navigation changes the document without changing the origin or the application logic. ```js import { createServer } from "node:http"; createServer((_request, response) => { response.setHeader("content-type", "text/html"); response.end(`<!doctype html><html><body> <pre id="state"></pre> <button id="update">Update</button> <button id="delete">Delete</button> <button id="empty">Empty</button> <script> function render() { document.querySelector('#state').textContent = JSON.stringify({ local: localStorage.getItem('state'), session: sessionStorage.getItem('state') }); } for (const action of ['update', 'delete', 'empty']) { document.getElementById(action).onclick = () => { for (const storage of [localStorage, sessionStorage]) { if (action === 'delete') storage.removeItem('state'); else storage.setItem('state', action === 'empty' ? '' : 'updated'); } render(); }; } render(); </script> </body></html>`); }).listen(8765, "127.0.0.1", () => { console.log("Storage fixture: http://127.0.0.1:8765"); }); ``` Start it in a separate terminal: ```sh node storage-site.mjs ``` Save this as `storage-scenario.json`. The executable shown is a standard Windows Chrome installation path; select the absolute executable path for the browser installed on your host. ```json { "browser": { "mode": "launch", "executable_path": "C:/Program Files/Google/Chrome/Application/chrome.exe", "headless": true }, "start_url": { "url": "http://127.0.0.1:8765/" }, "storage": { "local_storage": [{ "origin": "http://127.0.0.1:8765", "entries": [{ "name": "state", "value": { "source": "literal", "value": "initial" } }] }], "session_storage": [{ "origin": "http://127.0.0.1:8765", "entries": [{ "name": "state", "value": { "source": "literal", "value": "initial" } }] }] }, "actions": [ { "step_id": "update", "action": "click", "locator": { "kind": "css", "selector": "#update" }, "timeout_ms": 2000 }, { "step_id": "navigate", "action": "goto", "destination": { "url": "http://127.0.0.1:8765/next" }, "wait_until": "load", "timeout_ms": 2000 } ], "capture": { "after_each_step": ["dom", "storage"], "at_end": ["dom", "storage"] } } ``` Run the public CLI: ```sh node scripts/rea.mjs capture-browser-scenario storage-scenario.json --json ``` Inspect `normalized_result.steps`, particularly the captured DOM and storage artifacts for `scenario_start`, `update`, and `navigate`. ## Relevant observed evidence The executed baseline produced these values in both stores: | Step | Actual | Expected | | --- | --- | --- | | `scenario_start` | `initial` | `initial` | | `update` | `updated` | `updated` | | `navigate` | **`initial`** | **`updated`** | The storage value hashes independently agree with the page's own DOM observation: ```text initial: ac1b5c0961a7269b6a053ee64276ed0e20a7f48aefb9f67519539d23aaf10149 updated: 27eb5e51506c911f6fc4bb345c0d9db6f60415fceab7c18e1e9b862637415777 ``` The CLI exited successfully, reported `terminated-owned-process` cleanup, and marked the capture complete and equality-eligible. The control browser retained `updated` after navigation. ## Expected behavior The schema describes these values as **initial** storage. They should be available before the first application script reads the store, then become application-owned state. Later changes, deliberate removal, and valid empty strings must survive navigation. A declared origin first reached later in the scenario should still receive its initial values on its first visit. Initialization lifetime should belong to the capture that requested the seed. Starting a separate scenario can establish a new initial state, but navigation within one running scenario is part of the application's behavior under observation. This distinction also makes the result useful for differential analysis: two applications that handle persistence differently should produce different observations. If the observer restores the same input on every document, it can erase that difference and make a broken persistence flow look equivalent to a correct one. The supplied state must therefore stop acting as a recurring assignment once its storage area has been initialized. To reproduce the deletion variant, change the first locator to `#delete`: the next document should see `null`, not a restored seed. With `#empty`, it should see `""`. Checking whether a key currently exists cannot establish whether initialization has already happened; a removed key is a valid application outcome. ## Impact and scope Login/logout, shopping carts, preferences, and other persistence workflows can be misrepresented. For example, an application that removes a seeded session token during logout can have that value restored on its next navigation. This report establishes an observer-induced state reset; it does not claim a particular third-party application's authentication vulnerability. Cookie initialization already runs once through the context. The defect concerns the repeated web-storage writes. It occurs before Evidence projection, so changing the displayed artifacts would leave the altered application execution intact. ## Source-derived cause and fix direction `installStorageSeeds` in `src/browser/PlaywrightScenarioSession.ts` installs a persistent `page.addInitScript` containing unconditional `localStorage.setItem` and `sessionStorage.setItem` loops. Playwright runs that script for subsequent documents and child frames. There is no initialization owner outside the page to distinguish a first storage access from a later application mutation. A fix is prepared that initializes each actual Chromium storage area once, before application scripts, with provider-owned bookkeeping. Using the browser's storage key matters for attached browsers: a cross-site iframe and a later top-level visit to the same origin can address different partitioned stores. Consuming an origin-wide seed in the iframe would incorrectly leave the later top-level area uninitialized. The fix also preserves the selected tab's session-storage ownership and keeps initialization markers out of captured application storage. The corresponding implementation is available in https://github.com/morluto/rea/pull/1579. ## Environment and additional context The original CLI/control reproduction ran on Windows with Chrome `154.0.8037.98`, REA `6.3.0` at `9ffc6210`, and Node `24.11.1`. The affected session source is identical at the current contribution base, `c19e15a2`. The prepared fix was verified with pinned Node `24.18.0`, npm `11.16.0`, and Playwright `1.58.2`, including real launch and external-connect workflows. - [x] Searched open and closed issues and PRs for a matching storage-seed lifecycle report. - [x] Removed credentials, sensitive target data, and private filesystem paths.
bsakaguchi 2026-10-15 18:25:55 +00:00
Author
Owner

@morluto commented on GitHub (Oct 11, 2026):

Fixed on main in 8b4c3de3b, with PR #1579's author credited as co-author.

Storage initialization now belongs to the scenario's CDP owner. Local storage is seeded once per actual browser storage key; session storage also includes the top-level target identity. Application updates, deletion, and empty strings survive navigation, while a new scenario can establish new initial state. Cleanup uses CDP detach as terminal release and retains failed cleanup for retry. The owner resumes only its own bootstrap pause, preserving caller debugger pauses.

Validation: 11 focused ownership/cancellation tests, build:cached, check:fast, and docs:check passed. Dedicated real Chrome 154 launch/connect checks passed, including pre-script seeding, OOPIFs, partition identity, navigation persistence, cancellation, external-target survival, and natural shutdown. Windows-host verification and the complete broad browser verifier are not claimed.

The slow-test investigation used Flameox: the isolated real-browser storage helper passed in about 14 seconds, with 93.6% of Node samples idle. The baseline broad verifier also exceeded the 50-second diagnostic budget; that run is not reported as a pass or a proven hang.

Closing as fixed/integrated.

<!-- gh-comment-id:6104562073 --> @morluto commented on GitHub (Oct 11, 2026): Fixed on `main` in 8b4c3de3b, with PR #1579's author credited as co-author. Storage initialization now belongs to the scenario's CDP owner. Local storage is seeded once per actual browser storage key; session storage also includes the top-level target identity. Application updates, deletion, and empty strings survive navigation, while a new scenario can establish new initial state. Cleanup uses CDP detach as terminal release and retains failed cleanup for retry. The owner resumes only its own bootstrap pause, preserving caller debugger pauses. Validation: 11 focused ownership/cancellation tests, `build:cached`, `check:fast`, and `docs:check` passed. Dedicated real Chrome 154 launch/connect checks passed, including pre-script seeding, OOPIFs, partition identity, navigation persistence, cancellation, external-target survival, and natural shutdown. Windows-host verification and the complete broad browser verifier are not claimed. The slow-test investigation used Flameox: the isolated real-browser storage helper passed in about 14 seconds, with 93.6% of Node samples idle. The baseline broad verifier also exceeded the 50-second diagnostic budget; that run is not reported as a pass or a proven hang. Closing as fixed/integrated.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#529
No description provided.