Skip to content

Conversation

@nolanlawson
Copy link
Member

@nolanlawson nolanlawson commented Oct 25, 2025

Closes #476

The following tasks have been completed:

Implementation commitment:


Preview | Diff

@nolanlawson
Copy link
Member Author

Note I only added the minimal logic to mark the transaction inactive when a key is provided to IDBObjectStore#add/put. I did not:

  • mark the transaction inactive anywhere else that we convert a key to a value, e.g. indexedDB.cmp or IDBCursor#continue, because this matches Firefox's implementation (and Chromium's as well – both browsers just throw InternalError: too much recursion / RangeError: Maximum call stack size exceeded
  • refactor to reuse the existing logic from clone – this seemed like overkill since the logic was only needed in one place.

@asutherland
Copy link
Collaborator

asutherland commented Oct 26, 2025

Thanks for calling out other places where the spec converts a key to a value! (And for filing the spec issue in the first place!)

For places like indexedDB.cmp and the IDBKeyRange methods (only/lowerBound/upperBound/bound/includes) where we don't have a transaction context to mark inactive and there's no complex state management going on it seems fine to not change the logic.

But for continue and continuePrimaryKey we do explicitly have transactions and we do check that they are active and I think there is enough potentially interesting state management going on that it makes sense to consistently apply the same rationale we're using for add/put. In particular, it seems like we could be in an upgrade transaction when calling one of the continue methods and then nefarious code could attempt to delete the object store or index that the cursor is against with that method on the stack. I do think it's much less likely for code to be written in a way that could cause a security bug in this situation, but I do think it still applies that there's no reasonable use-case for content to be doing tricky things here and I do think it also simplifies things from a spec perspective.

For example, step 3 of both continue methods is to throw if the underlying source/object store has been deleted. If we don't mark the transaction as inactive, then deleteObjectStore currently is defined to synchronously delete the store; we "destroy store" without ever going "in parallel" or doing async hand-waving. deleteIndex also synchronously does "destroy index" although the 2nd para afterwards does do some hand-waving: "Although this method does not return an IDBRequest object, the index destruction itself is processed as an asynchronous request within the upgrade transaction." So we would side-step these edge-cases where the index/objectStore could conceptually be deleted partway through the algorithm and any need to test/specify that by making the transaction inactive during the key conversions.

@nolanlawson
Copy link
Member Author

Thanks for the feedback @asutherland! I just checked, and it appears that none of Firefox/Chromium/WebKit actually throw a TransactionInactiveError in the case of continue/continuePrimaryKey (see test draft below).

Click to see test draft
promise_test(async testCase => {
  const db = await createDatabase(testCase, database => {
    database.createObjectStore('store');
  });

  const transaction = db.transaction(['store'], 'readwrite');
  const objectStore = transaction.objectStore('store');

  objectStore.put({}, 0);
  objectStore.put({}, 1);
  const cursor = await new Promise((resolve, reject) => {
    const cursorReq = objectStore.openCursor();
    cursorReq.onerror = reject;
    cursorReq.onsuccess = e => resolve(e.target.result);
  });

  let getterCalled = false;
  const activeKey = ['value that should not be used'];
  Object.defineProperty(activeKey, '0', {
    enumerable: true,
    get: testCase.step_func(() => {
      getterCalled = true;
      assert_throws_dom('TransactionInactiveError', () => {
        objectStore.get('key');
      }, 'transaction should not be active during key serialization');
      return 'value that should not be used';
    }),
  });
  cursor.continue(activeKey);
  await promiseForTransaction(testCase, transaction);
  db.close();

  assert_true(getterCalled,
    "activeKey's getter should be called during test");
}, 'Transaction inactive during key serialization in IDBCursor.continue()');

promise_test(async testCase => {
  const db = await createDatabase(testCase, database => {
    const objectStore = database.createObjectStore('store');
    objectStore.createIndex('idx', 'name');
  });

  const transaction = db.transaction(['store'], 'readwrite');
  const objectStore = transaction.objectStore('store');

  objectStore.put({ name: 'a' }, 0);
  objectStore.put({ name: 'b' }, 1);
  const idx = objectStore.index('idx')
  const cursor = await new Promise((resolve, reject) => {
    const cursorReq = idx.openCursor();
    cursorReq.onerror = reject;
    cursorReq.onsuccess = e => resolve(e.target.result);
  });

  let getterCalled = false;
  const activeKey = ['value that should not be used'];
  Object.defineProperty(activeKey, '0', {
    enumerable: true,
    get: testCase.step_func(() => {
      getterCalled = true;
      assert_throws_dom('TransactionInactiveError', () => {
        objectStore.get('key');
      }, 'transaction should not be active during key serialization');
      return 'value that should not be used';
    }),
  });
  cursor.continuePrimaryKey(activeKey, 0);
  await promiseForTransaction(testCase, transaction);
  db.close();

  assert_true(getterCalled,
    "activeKey's getter should be called during test");
}, 'Transaction inactive during key serialization in IDBCursor.continuePrimaryKey()');

Firefox does throw for put/add, though, which is why I originally scoped the fix to just that. Are you proposing that we handle continue/continuePrimaryKey in the spec despite Firefox's current behavior?

@nolanlawson
Copy link
Member Author

After re-reading your comment, I realized you weren't talking specifically about Firefox's implementation. I agree we can make this work with IDBCursor#continue / continuePrimaryKey as well. Updated the spec language and updated the tests at web-platform-tests/wpt#55660 .

@asutherland
Copy link
Collaborator

After re-reading your comment, I realized you weren't talking specifically about Firefox's implementation.

Yes.

I agree we can make this work with IDBCursor#continue / continuePrimaryKey as well. Updated the spec language and updated the tests at web-platform-tests/wpt#55660 .

Thank you; I like the introduction of the wrapper, this seems very clean!

@evanstade and @SteveBeckerMSFT in #476 I think we were initially only talking about matching Firefox; are you okay with the (consistent) expansion to also cover the cursor continue methods?

@evanstade
Copy link
Contributor

There are a lot of methods that take a key as an argument and are associated with a transaction --- many of the ones in ObjectStore, not just put and add. Why are those safe as-is if we think IDBCursor#continue should be hardened?

Just based on the name alone, converting a value to a key during a transaction sounds like something that should universally be used over converting a value to a key if there is an associated transaction. Otherwise we should come up with a name for it that better distinguishes when it should be referenced in the spec.

@asutherland
Copy link
Collaborator

Yes, I agree we should expand the mechanism to cover convert a value to a key range too if you're on board since the same rationale applies. I somehow had a lot of tunnel vision going on and ignored that algorithm and its many uses.

@evanstade
Copy link
Contributor

Yeah it makes sense to apply the change more broadly.

(Sorry for slow reply.)

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.

Transactions should be marked as inactive during key serialization

4 participants