Skip to content

Commit 4e9e56b

Browse files
mxaminclaude
andcommitted
Never evict a live id registration (#356)
The shadow's id registry held no reference to the documents it recorded, so entries could only be validated by a stored _c_doc address and the dict was capped at 4096 to bound growth from dead documents. At the cap it deleted an arbitrary entry, which could be a live document's: a long-running process that keeps an early signed document and registers ids for 4096 later ones silently stopped resolving that document's #id references. An entry now keeps a strong reference to its _Document. The key, the document's address, can then never go stale — the entry itself keeps that address occupied — so the _c_doc check is gone; and liveness becomes decidable without weak references, which lxml's classes refuse: when the registry holds the only reference to a document, nothing can present that document to a binding again, so the entry is dead. Every new registration first drops those entries, which bounds both the registry and the documents it pins by the documents still in use. No cap, and nothing reachable is ever evicted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 6cc57b2 commit 4e9e56b

4 files changed

Lines changed: 80 additions & 55 deletions

File tree

‎developer.md‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,8 @@ accepts an lxml element (`PyXmlSec_LxmlElementConverter`) either calls a
9999
`PyXmlSec_LxmlShadowBegin*` helper or is one of the four dual-body functions
100100
(`register_id`, `add_ids`, `encrypt_xml`, `decrypt`), which must consult
101101
`IsActive()` before touching a raw node; and `->_c_node` / `->_c_doc` appear
102-
only in those four, in the helpers' fast-path branches, and in the ID registry
103-
(which uses the addresses as keys only). The test scans `src/*.c`, so a raw
104-
access anywhere else fails the suite on both paths.
102+
only in those four and in the helpers' fast-path branches. The test scans
103+
`src/*.c`, so a raw access anywhere else fails the suite on both paths.
105104

106105
## How the reflection works
107106

@@ -198,9 +197,12 @@ document identity (`RecordId`), and every `BeginDoc` replays them onto its
198197
copy so that `#id` references resolve during sign/verify/decrypt. The replay
199198
scans the whole copy for the recorded attribute names — a superset of the
200199
single-node registration on the raw path, mirroring what `xmlSecAddIDs` does
201-
from the root. The registry holds no strong references to documents (lxml's
202-
classes refuse weak references), so entries are validated by a stored
203-
`_c_doc` address and capped in size. The two bindings are the only places,
200+
from the root. lxml's classes refuse weak references, so an entry keeps a
201+
strong reference to its document instead: the key (the document's address)
202+
can then never go stale, and a document referenced by nothing but the
203+
registry is provably unreachable, so its entry — and the document with it —
204+
is dropped before the next registration. The registry therefore tracks the
205+
documents still in use and never evicts a live one. The two bindings are the only places,
204206
together with encrypt_xml/decrypt's replacement bodies, that branch on
205207
`IsActive()`.
206208

‎src/lxml.c‎

Lines changed: 43 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -491,20 +491,49 @@ void PyXmlSec_LxmlShadowDiscard(PyXmlSec_LxmlShadow* shadow) {
491491
// and every whole-document Begin replays them onto the private copy so that
492492
// #id references resolve during sign/verify/decrypt.
493493
//
494-
// The registry cannot hold strong references to lxml documents (that would
495-
// pin whole trees forever) and lxml's classes refuse weak references, so
496-
// entries are keyed by the _Document object's address with the underlying
497-
// xmlDoc pointer stored alongside as a staleness check: an entry is trusted
498-
// only while both addresses match, and is replaced when the address has been
499-
// reused by a different document. A size cap bounds growth from dead
500-
// documents whose addresses never get reused.
494+
// An entry is keyed by the _Document object's address and holds a strong
495+
// reference to that document, so the key can never go stale: the address
496+
// cannot be reused while the entry keeps the object alive. That reference
497+
// also makes liveness decidable without weak references (lxml's classes
498+
// refuse those): when the registry holds the *only* reference to a document,
499+
// nothing can hand that document to a binding again, so the entry is dead and
500+
// is dropped. Pruning runs before every new registration, which bounds the
501+
// registry — and the documents it pins — by the documents still in use. No
502+
// live registration is ever evicted.
501503
// ----------------------------------------------------------------------------
502504

503-
#define PYXMLSEC_SHADOW_ID_REGISTRY_CAP 4096
505+
// Drops the entries of documents nobody but the registry still references.
506+
// Best effort: on an allocation failure the entries simply survive until the
507+
// next registration prunes them.
508+
static void PyXmlSec_LxmlShadowPruneIdRegistry(void) {
509+
PyObject* dead;
510+
PyObject* key;
511+
PyObject* entry;
512+
Py_ssize_t pos = 0;
513+
Py_ssize_t i;
514+
515+
dead = PyList_New(0);
516+
if (dead == NULL) {
517+
PyErr_Clear();
518+
return;
519+
}
520+
// The dict cannot be mutated while iterating it, so collect first.
521+
while (PyDict_Next(PyXmlSec_LxmlShadowIdRegistry, &pos, &key, &entry)) {
522+
if (Py_REFCNT(PyTuple_GET_ITEM(entry, 0)) == 1 && PyList_Append(dead, key) < 0) {
523+
PyErr_Clear();
524+
break;
525+
}
526+
}
527+
for (i = 0; i < PyList_GET_SIZE(dead); ++i) {
528+
if (PyDict_DelItem(PyXmlSec_LxmlShadowIdRegistry, PyList_GET_ITEM(dead, i)) < 0) {
529+
PyErr_Clear();
530+
}
531+
}
532+
Py_DECREF(dead);
533+
}
504534

505535
int PyXmlSec_LxmlShadowRecordId(PyXmlSec_LxmlElementPtr element, const char* name, const char* ns) {
506536
PyObject* key = NULL;
507-
PyObject* cdoc = NULL;
508537
PyObject* created = NULL;
509538
PyObject* spec = NULL;
510539
PyObject* entry;
@@ -513,36 +542,18 @@ int PyXmlSec_LxmlShadowRecordId(PyXmlSec_LxmlElementPtr element, const char* nam
513542
int result = -1;
514543

515544
key = PyLong_FromVoidPtr((void*)element->_doc);
516-
cdoc = PyLong_FromVoidPtr((void*)element->_doc->_c_doc);
517-
if (key == NULL || cdoc == NULL) {
545+
if (key == NULL) {
518546
goto DONE;
519547
}
520548

521549
entry = PyDict_GetItem(PyXmlSec_LxmlShadowIdRegistry, key); // borrowed
522-
if (entry != NULL) {
523-
int eq = PyObject_RichCompareBool(PyTuple_GET_ITEM(entry, 0), cdoc, Py_EQ);
524-
if (eq < 0) {
525-
goto DONE;
526-
}
527-
if (!eq) {
528-
entry = NULL; // the address was reused by another document
529-
}
530-
}
531550
if (entry == NULL) {
532-
if (PyDict_Size(PyXmlSec_LxmlShadowIdRegistry) >= PYXMLSEC_SHADOW_ID_REGISTRY_CAP) {
533-
PyObject* k;
534-
PyObject* v;
535-
Py_ssize_t pos = 0;
536-
if (PyDict_Next(PyXmlSec_LxmlShadowIdRegistry, &pos, &k, &v)
537-
&& PyDict_DelItem(PyXmlSec_LxmlShadowIdRegistry, k) < 0) {
538-
goto DONE;
539-
}
540-
}
551+
PyXmlSec_LxmlShadowPruneIdRegistry();
541552
specs = PyList_New(0);
542553
if (specs == NULL) {
543554
goto DONE;
544555
}
545-
created = PyTuple_Pack(2, cdoc, specs);
556+
created = PyTuple_Pack(2, (PyObject*)element->_doc, specs);
546557
Py_DECREF(specs);
547558
if (created == NULL || PyDict_SetItem(PyXmlSec_LxmlShadowIdRegistry, key, created) < 0) {
548559
goto DONE;
@@ -563,7 +574,6 @@ int PyXmlSec_LxmlShadowRecordId(PyXmlSec_LxmlElementPtr element, const char* nam
563574

564575
DONE:
565576
Py_XDECREF(key);
566-
Py_XDECREF(cdoc);
567577
Py_XDECREF(created);
568578
Py_XDECREF(spec);
569579
return result;
@@ -596,33 +606,21 @@ static void PyXmlSec_LxmlShadowApplyIdSpec(xmlDocPtr doc, xmlNodePtr n, const xm
596606
// Replays the specs recorded for the shadow's live document onto the copy.
597607
static int PyXmlSec_LxmlShadowReplayIds(PyXmlSec_LxmlShadow* shadow) {
598608
PyObject* key;
599-
PyObject* cdoc;
600609
PyObject* entry;
601610
PyObject* specs;
602611
Py_ssize_t i, n;
603-
int eq;
604612

605613
key = PyLong_FromVoidPtr((void*)shadow->element->_doc);
606614
if (key == NULL) {
607615
return -1;
608616
}
617+
// The entry, if any, belongs to this very document: the registry's own
618+
// reference keeps the address from being reused by another one.
609619
entry = PyDict_GetItem(PyXmlSec_LxmlShadowIdRegistry, key); // borrowed
610620
Py_DECREF(key);
611621
if (entry == NULL) {
612622
return 0;
613623
}
614-
cdoc = PyLong_FromVoidPtr((void*)shadow->element->_doc->_c_doc);
615-
if (cdoc == NULL) {
616-
return -1;
617-
}
618-
eq = PyObject_RichCompareBool(PyTuple_GET_ITEM(entry, 0), cdoc, Py_EQ);
619-
Py_DECREF(cdoc);
620-
if (eq < 0) {
621-
return -1;
622-
}
623-
if (!eq) {
624-
return 0; // stale entry from a dead document at the same address
625-
}
626624

627625
specs = PyTuple_GET_ITEM(entry, 1);
628626
n = PyList_GET_SIZE(specs);

‎tests/test_ds.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import unittest
22

3+
from lxml import etree
4+
35
import xmlsec
46
from tests import base
57

@@ -367,3 +369,28 @@ def test_set_enabled_key_data_bad_list(self):
367369
ctx.key = xmlsec.Key.from_file(self.path('rsakey.pem'), format=consts.KeyDataFormatPem)
368370
with self.assertRaisesRegex(TypeError, 'expected list of KeyData constants.'):
369371
ctx.set_enabled_key_data('foo')
372+
373+
374+
class TestIdRegistryLifetime(base.TestMemoryLeaks):
375+
"""Guards the lifetime rule of the shadow-mode id registry (issue #356)."""
376+
377+
# The document churn below is this test's point; the leak harness would repeat it ten times.
378+
iterations = 0
379+
380+
# Far more documents than any bounded registry would have held at once.
381+
OTHER_DOCUMENTS = 5000
382+
383+
def test_registration_survives_other_live_documents(self):
384+
"""Registering ids for other documents must never drop a live document's own registration."""
385+
root = self.load_xml('sign4-out.xml')
386+
ctx = xmlsec.SignatureContext()
387+
ctx.register_id(root, 'ID')
388+
389+
# All kept alive, so none of these registrations may be traded for another.
390+
others = [etree.fromstring(f'<Envelope ID="doc-{i}"/>') for i in range(self.OTHER_DOCUMENTS)]
391+
for other in others:
392+
ctx.register_id(other, 'ID')
393+
394+
sign = xmlsec.tree.find_node(root, consts.NodeSignature)
395+
ctx.key = xmlsec.Key.from_file(self.path('rsapub.pem'), format=consts.KeyDataFormatPem)
396+
ctx.verify(sign) # resolves #ID through the registration made before the churn

‎tests/test_shadow_audit.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,15 @@
3232
}
3333
)
3434

35-
# Functions allowed to dereference lxml's raw node/document pointers: the dual bodies above,
36-
# the fast-path branches of the shadow helpers, and the ID registry (addresses used as keys).
35+
# Functions allowed to dereference lxml's raw node/document pointers: the dual bodies above
36+
# and the fast-path branches of the shadow helpers.
3737
RAW_ACCESS_ALLOWED = DUAL_BODY_FUNCTIONS | frozenset(
3838
{
3939
'PyXmlSec_LxmlShadowBegin',
4040
'PyXmlSec_LxmlShadowBeginDoc',
4141
'PyXmlSec_LxmlShadowBeginNewDoc',
4242
'PyXmlSec_LxmlShadowEnd',
4343
'PyXmlSec_LxmlShadowEndFind',
44-
'PyXmlSec_LxmlShadowRecordId',
45-
'PyXmlSec_LxmlShadowReplayIds',
4644
}
4745
)
4846

0 commit comments

Comments
 (0)