Skip to content

fix(vault): make a large OpenCreds import possible at all - #26

Merged
ralyodio merged 1 commit into
masterfrom
vault-import-fix
Sep 2, 2026
Merged

fix(vault): make a large OpenCreds import possible at all#26
ralyodio merged 1 commit into
masterfrom
vault-import-fix

Conversation

@ralyodio

@ralyodio ralyodio commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Importing an OpenCreds database into the vault wrote nothing and looked like a crash: the window vanished and the vault stayed empty. vault_items in prod confirms it — zero rows for every user, so not one POST was ever sent.

Three separate defects sit between choosing a file and seeing the items.

1. The import ran in the action popup, where it cannot work

A popup is dismissed as soon as it loses focus, and both halves of the flow took focus away: the file chooser, and then window.prompt for the export passphrase. Chrome suppresses JS dialogs in an action popup, so the prompt returned null and the handler took its passphrase === null cancel path — silently, with no message — if the popup had not already closed under the file chooser.

Moved to the options page, an ordinary extension tab: it survives a file chooser, asks for the passphrase in the page, and stays open long enough to show progress. The popup's Import control now opens it. No window.prompt/window.confirm on this path.

2. Writes were sequential, and the caller awaited all of them

The API creates one item per request, so a few thousand keys is a few thousand round trips — minutes, during which the page that started it is long gone and the result goes nowhere.

Writes now run 8 at a time; the call returns once the work is scheduled and progress is polled, so closing the page no longer abandons the import. A service-worker restart still loses the job, so re-running the same file resumes instead: every item carries its own id, an id already stored comes back 409, and that is counted as already-present rather than as a failure. Re-running the same file is therefore safe and does not duplicate.

Item data is deliberately kept out of any persisted progress record — nothing that would put plaintext secrets somewhere to survive a restart.

3. The list call fetched one page

The server has always capped a response at MAX_PAGE_SIZE (1000) and there was no way to ask for the rest, so any vault larger than that was listed short with no error anywhere — the items were stored and simply never shown.

GET /api/vault/items accepts offset, orders by (updated_at, id) so paging is total across the thousands of rows an import writes in the same instant, and reports paged: true. The client pages until a short response. The flag matters: without it a client cannot tell a paging server from one that ignores the parameter and would loop on the same page forever — so this is safe to ship before the web deploy.

Verification

  • Full suites: 793 extension tests pass (30 files). The 2 failures in apps/web/__tests__/extension-auth.test.js are pre-existing on master and unrelated.
  • New tests: large import schedules and completes in the background; already-stored items count as present so re-running resumes without duplicating; a second concurrent import is refused; paging returns all 3,122 of an over-page vault, makes one request when everything fits, and stops after one page against a server that does not understand offset.
  • End-to-end against a real 3,122-item OpenCreds database: all 3,122 parse, encrypt, store and read back with every value byte-identical.

Note

The extension change works against the current production API; a vault larger than 1000 items will simply list the first 1000 until apps/web is deployed. Nothing is lost in the meantime — the items are stored and appear once it ships.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q79wCDBvrPetsms1HCcnoX

Importing an OpenCreds database wrote nothing and looked like a crash: the
window vanished and the vault stayed empty. Three separate defects, all on
the path between choosing a file and seeing the items.

The import ran in the action popup. A popup is dismissed as soon as it loses
focus, and both halves of the flow took focus away -- the file chooser, and
then `window.prompt` for the export passphrase. Chrome suppresses JS dialogs
in a popup, so the prompt returned null and the handler took its
`passphrase === null` cancel path, silently and with no message, if the popup
had not already closed under the file chooser. Not one item was ever sent.
The flow moves to the options page, an ordinary extension tab, which survives
a file chooser and can ask for the passphrase in the page.

Items were then written one POST at a time, sequentially, with the caller
awaiting all of them. A few thousand keys is a few thousand round trips --
minutes during which the page that started it is gone and the result goes
nowhere. Writes now run eight at a time, the call returns once the work is
scheduled, and progress is polled. A worker restart still loses the job, so
re-running the same file resumes instead: every item carries its own id, and
an id already stored comes back 409, which is now counted as already-present
rather than as a failure.

Finally the list call fetched one page. The server has always capped a
response at 1000 items and there was no way to ask for the rest, so any vault
larger than that was listed short with no error -- the items were stored and
never shown. GET accepts an offset, orders by (updated_at, id) so paging is
total across rows written in the same instant, and reports `paged` so a
client can tell a paging server from one that ignores the parameter.

Verified against a real 3,122-item database: all 3,122 parse, encrypt, store
and read back with every value byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q79wCDBvrPetsms1HCcnoX
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

11 finding(s)

MEDIUM: 7 | LOW: 4

Severity Rule Location
MEDIUM js-open-redirect apps/extension/src/blocked/main.js:84
MEDIUM sql-template-interpolation apps/web/app/api/account/delete/route.js:104
MEDIUM js-credential-logged apps/web/app/api/health/route.js:27
MEDIUM js-open-redirect apps/web/app/dashboard/sync-sources-client.jsx:88
MEDIUM js-unescaped-html-sink apps/web/app/layout.jsx:86
MEDIUM js-unescaped-html-sink apps/web/app/layout.jsx:110
MEDIUM js-open-redirect apps/web/app/pricing/page.jsx:178
LOW secret-generic-credential apps/web/__tests__/auth-api.test.js:541
LOW redos-nested-quantifier packages/sources/__tests__/dropbox-oauth.test.ts:72
LOW secret-generic-credential packages/vault/__tests__/items.test.js:201
LOW secret-generic-credential packages/vault/__tests__/items.test.js:216

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit fd0a8e0 into master Sep 2, 2026
9 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