Skip to content

Commit 3367b39

Browse files
mxaminclaude
andcommitted
Refuse a duplicate id at register_id under the shadow (#356)
One finding from the review of the shadow path. `register_id` raises "duplicated id." on the raw path when the id value it is asked to register is already registered for another attribute, and under the shadow it silently recorded the spec instead: the replay's first-wins rule then skipped it, so the call succeeded while the caller's `#id` reference resolved to whichever element claimed the value first — an earlier registration, a DTD-declared id attribute or an xml:id. The check is back at the call that makes the registration, where the fast path has it, rather than at the replay: raising from a later sign/verify would report the collision from the wrong call and then from every later call on that document. `xmlGetID(doc, value) != attr` is assembled from the two places a registration can live under the shadow — what lxml's own parse declared, read back through XPath's id(), which crosses the library boundary as strings and elements only, and what earlier register_id or add_ids calls recorded in the registry. Re-registering the same attribute stays the no-op the fast path performs, and add_ids keeps xmlSecAddIDs' own first-wins semantics, which never raise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 7e279cf commit 3367b39

5 files changed

Lines changed: 343 additions & 86 deletions

File tree

developer.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ End always releases the copy, on success and on error.
7171

7272
Plus three helpers for the call sites whose *semantics* differ per mode:
7373
`IsActive()` (which path is on), `ImportElement()` (encrypt_xml's template
74-
import into the shadow document) and `RecordId()` (ID registration, below).
74+
import into the shadow document) and `RegisterId()` (ID registration, below).
7575

7676
Which pair a binding uses follows from what the xmlsec call does:
7777

@@ -201,7 +201,7 @@ lxml's own dump of a tree it already parsed.
201201
`SignatureContext.register_id` and `tree.add_ids` used to write lxml's ID
202202
hash with our libxml2 — exactly the cross-library access the shadow forbids.
203203
Under the shadow they record the id-attribute specs in a registry keyed by
204-
document identity (`RecordId`), and every `BeginDoc` replays them onto its
204+
document identity (`RegisterId`, `RecordIds`), and every `BeginDoc` replays them onto its
205205
copy so that `#id` references resolve during sign/verify/decrypt. An entry
206206
keeps the registered elements themselves, so a spec is replayed at exactly
207207
the node it was registered for — that node alone for `register_id`, its
@@ -219,6 +219,19 @@ registration. The registry therefore tracks the documents still in use and
219219
never evicts a live one. The two bindings are the only places, together with
220220
encrypt_xml/decrypt's replacement bodies, that branch on `IsActive()`.
221221

222+
`register_id` still refuses a duplicate id the way the fast path does, and at
223+
the same call: `xmlGetID(doc, value) != attr` — the test that raises
224+
`duplicated id.` — is assembled from the two places a registration can live
225+
under the shadow. What lxml's own parse declared (a DTD id attribute, an
226+
`xml:id`) is read back through XPath's `id()`, the one door into lxml's id
227+
hash that passes nothing but strings and elements; what earlier
228+
`register_id`/`add_ids` calls claimed is read from the registry, a subtree
229+
spec through one XPath over its scope. Deferring the check to the replay
230+
instead would raise from the wrong call — a later `sign`, and then from every
231+
later call on that document — and would leave the caller believing a
232+
registration took that can never win the lookup. `add_ids` keeps its own
233+
semantics: `xmlSecAddIDs` registers first-wins and never raises.
234+
222235
## Converting a binding
223236

224237
1. Classify the xmlsec call with the table above.
@@ -246,7 +259,11 @@ All invisible to the documented API:
246259
document until grafted;
247260
- `encrypted_data_ensure_key_info(ns=...)` on an existing `KeyInfo` returns
248261
a new element object rather than the original proxy;
249-
- `register_id` skips the live duplicate-id check (it runs per copy instead);
262+
- `register_id`'s duplicate-id check cannot see an id value that contains
263+
whitespace among lxml's declared ids (XPath's `id()` would read it as a
264+
list of ids), so such a value is compared against the registry alone; a
265+
registration whose element has since been adopted into another document
266+
claims nothing, as at replay;
250267
- `encrypt_xml` encrypts a *copy* of the template, so the caller's template
251268
proxy is not the returned element; a template attached in the target's own
252269
document is unlinked afterwards (keeping its tail text where libxml2 would

src/ds.c

Lines changed: 4 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -119,50 +119,6 @@ static int PyXmlSec_SignatureContextKeySet(PyObject* self, PyObject* value, void
119119
return 0;
120120
}
121121

122-
// Non-zero when `node` carries an attribute whose *local* name is `name`,
123-
// whatever its namespace, -1 with an exception set on failure. The fast
124-
// path's xmlHasProp() matches that way, while lxml's node.get(name) only
125-
// finds an unqualified attribute; the shadow's validation goes through this
126-
// so that both modes accept the same calls.
127-
static int PyXmlSec_LxmlHasAttrByLocalName(PyXmlSec_LxmlElementPtr node, const char* name) {
128-
PyObject* keys;
129-
PyObject* seq;
130-
Py_ssize_t i;
131-
Py_ssize_t n;
132-
int found = 0;
133-
134-
keys = PyObject_CallMethod((PyObject*)node, "keys", NULL);
135-
if (keys == NULL) {
136-
return -1;
137-
}
138-
seq = PySequence_Fast(keys, "unexpected attribute names.");
139-
Py_DECREF(keys);
140-
if (seq == NULL) {
141-
return -1;
142-
}
143-
144-
n = PySequence_Fast_GET_SIZE(seq);
145-
for (i = 0; i < n && !found; ++i) {
146-
PyObject* key = PySequence_Fast_GET_ITEM(seq, i); // borrowed
147-
const char* local;
148-
const char* end;
149-
if (!PyUnicode_Check(key) || (local = PyUnicode_AsUTF8(key)) == NULL) {
150-
if (!PyErr_Occurred()) {
151-
PyErr_SetString(PyXmlSec_InternalError, "unexpected attribute name.");
152-
}
153-
Py_DECREF(seq);
154-
return -1;
155-
}
156-
// lxml spells a namespaced attribute "{href}local".
157-
if (local[0] == '{' && (end = strchr(local, '}')) != NULL) {
158-
local = end + 1;
159-
}
160-
found = strcmp(local, name) == 0;
161-
}
162-
Py_DECREF(seq);
163-
return found;
164-
}
165-
166122
static const char PyXmlSec_SignatureContextRegisterId__doc__[] = \
167123
"register_id(node, id_attr = 'ID', id_ns = None) -> None\n"
168124
"Registers new id.\n\n"
@@ -191,37 +147,11 @@ static PyObject* PyXmlSec_SignatureContextRegisterId(PyObject* self, PyObject* a
191147
}
192148

193149
// Shadow mode: never touch lxml's document (its ID hash) with our libxml2
194-
// (issue #356). Validate through lxml's API and record the spec; the
195-
// whole-document shadows (sign/verify/decrypt) replay it onto their
196-
// private copies. The duplicate-id check runs there, per copy.
150+
// (issue #356). The registration is validated through lxml's API and
151+
// recorded instead; the whole-document shadows (sign/verify/decrypt)
152+
// replay it onto their private copies.
197153
if (PyXmlSec_LxmlShadowIsActive()) {
198-
int found;
199-
if (id_ns != NULL) {
200-
PyObject* key = PyUnicode_FromFormat("{%s}%s", id_ns, id_attr);
201-
PyObject* value;
202-
if (key == NULL) {
203-
goto ON_FAIL;
204-
}
205-
value = PyObject_CallMethod((PyObject*)node, "get", "O", key);
206-
Py_DECREF(key);
207-
if (value == NULL) {
208-
goto ON_FAIL;
209-
}
210-
found = value != Py_None;
211-
Py_DECREF(value);
212-
} else {
213-
// As xmlHasProp() does on the fast path: by local name.
214-
found = PyXmlSec_LxmlHasAttrByLocalName(node, id_attr);
215-
if (found < 0) {
216-
goto ON_FAIL;
217-
}
218-
}
219-
if (!found) {
220-
PyErr_SetString(PyXmlSec_Error, "missing attribute.");
221-
goto ON_FAIL;
222-
}
223-
// Scope 0: this node alone, exactly what the fast path registers.
224-
if (PyXmlSec_LxmlShadowRecordId(node, id_attr, id_ns, 0) < 0) {
154+
if (PyXmlSec_LxmlShadowRegisterId(node, id_attr, id_ns) < 0) {
225155
goto ON_FAIL;
226156
}
227157
PYXMLSEC_DEBUGF("%p: register id - ok", self);

0 commit comments

Comments
 (0)