Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b620369
Decouple lxml from xmlsec via shadow copies (#356)
mxamin Jul 2, 2026
9a7b1a6
Add step-by-step guide for converting functions to shadow copies
mxamin Jul 2, 2026
e939f1e
Add high-level summary of the shadow-copy solution for #356
mxamin Jul 2, 2026
72a78cb
Skip the shadow copy when lxml links the same libxml2
mxamin Jul 2, 2026
92d68ad
Convert every remaining raw-node crossing to shadow copies (#356)
mxamin Jul 7, 2026
8a487f2
Unify the shadow reflection engine and prune its API (#356)
mxamin Sep 4, 2026
66a6742
Guard the shadow invariants with a source-level test (#356)
mxamin Sep 4, 2026
a3b1fce
Reflect root replacement by morphing the live root in place (#356)
mxamin Sep 4, 2026
6cc57b2
Match id attributes by local name under the shadow (#356)
mxamin Sep 4, 2026
4e9e56b
Never evict a live id registration (#356)
mxamin Sep 4, 2026
302c851
Read code, not comments, in the shadow audit (#356)
mxamin Sep 4, 2026
97cb993
Drop the source-level shadow audit (#356)
mxamin Sep 4, 2026
38174b9
Replay id registrations at the node they were registered for (#356)
mxamin Sep 4, 2026
a4f83e3
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Sep 4, 2026
767deb5
Merge branch 'fix/356-shadow-copy' of github.com:xmlsec/python-xmlsec…
mxamin Sep 4, 2026
9c36ae7
Keep entity references and adopted nodes straight under the shadow (#…
mxamin Sep 4, 2026
7e279cf
Move an attached encrypt_xml template, fingerprint text (#356)
mxamin Sep 4, 2026
3367b39
Refuse a duplicate id at register_id under the shadow (#356)
mxamin Sep 4, 2026
3a50b04
Bound the copy's walks, load an external DTD subset (#356)
mxamin Sep 4, 2026
48e8d0d
Tell two same-valued attributes of one element apart (#356)
mxamin Sep 4, 2026
ea7dace
Skip the external-DTD tests where nothing may load one (#356)
mxamin Sep 4, 2026
b86e624
Copy a subtree its document lost as an unlinked node (#356)
mxamin Sep 5, 2026
ff6a0a6
Snapshot add_ids' scope, retire dead registrations (#356)
mxamin Sep 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/scripts/manylinux_build_and_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ echo "== [container] Step: Install test dependencies =="
echo "== [container] Step: Run tests =="
/opt/python/${PY_ABI}/bin/pytest -v --color=yes

# Step: Run tests again on the shadow-copy path (issue #356), which the
# matched libxml2 build would otherwise never exercise.
echo "== [container] Step: Run tests (forced shadow path) =="
PYXMLSEC_FORCE_SHADOW=1 /opt/python/${PY_ABI}/bin/pytest -v --color=yes

# Step: Fix mounted workspace file ownership on host
echo "== [container] Step: Fix mounted workspace file ownership on host =="
chown -R "${HOST_UID}:${HOST_GID}" dist wheelhouse build libs || true
3 changes: 3 additions & 0 deletions .github/workflows/linuxbrew.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,6 @@ jobs:
pip3 install --upgrade --no-binary=lxml -r requirements-test.txt
pip3 install xmlsec --only-binary=xmlsec --no-index --find-links=dist/
pytest -v --color=yes
# Same suite on the shadow-copy path (issue #356), which the
# matched libxml2 build would otherwise never exercise.
PYXMLSEC_FORCE_SHADOW=1 pytest -v --color=yes
8 changes: 8 additions & 0 deletions .github/workflows/macosx.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ jobs:
run: |
coverage run -m pytest -v --color=yes

# Same suite on the shadow-copy path (issue #356), which the matched
# libxml2 build would otherwise never exercise.
- name: Run tests (forced shadow path)
env:
PYXMLSEC_FORCE_SHADOW: "1"
run: |
pytest -v --color=yes

- name: Report coverage to codecov
if: matrix.static_deps != 'static'
run: |
Expand Down
411 changes: 411 additions & 0 deletions developer.md

Large diffs are not rendered by default.

37 changes: 33 additions & 4 deletions src/ds.c
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,18 @@ static PyObject* PyXmlSec_SignatureContextRegisterId(PyObject* self, PyObject* a
goto ON_FAIL;
}

// Shadow mode: never touch lxml's document (its ID hash) with our libxml2
// (issue #356). The registration is validated through lxml's API and
// recorded instead; the whole-document shadows (sign/verify/decrypt)
// replay it onto their private copies.
if (PyXmlSec_LxmlShadowIsActive()) {
if (PyXmlSec_LxmlShadowRegisterId(node, id_attr, id_ns) < 0) {
goto ON_FAIL;
}
PYXMLSEC_DEBUGF("%p: register id - ok", self);
Py_RETURN_NONE;
}

if (id_ns != NULL) {
attr = xmlHasNsProp(node->_c_node, XSTR(id_attr), XSTR(id_ns));
} else {
Expand Down Expand Up @@ -189,19 +201,28 @@ static PyObject* PyXmlSec_SignatureContextSign(PyObject* self, PyObject* args, P

PyXmlSec_SignatureContext* ctx = (PyXmlSec_SignatureContext*)self;
PyXmlSec_LxmlElementPtr node = NULL;
xmlNodePtr target;
PyXmlSec_LxmlShadow shadow;
int rv;

PYXMLSEC_DEBUGF("%p: sign - start", self);
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O&:sign", kwlist, PyXmlSec_LxmlElementConverter, &node)) {
goto ON_FAIL;
}

// References (URI="", "#id") reach anywhere in the document, so the
// shadow covers the whole tree (issue #356), with the registered IDs
// replayed onto the copy. Signing fills several places inside
// <Signature> (DigestValue, SignatureValue, KeyInfo); the reflect carries
// them all back.
if (PyXmlSec_LxmlShadowBeginDoc(&shadow, node, &target) < 0) {
goto ON_FAIL;
}
Py_BEGIN_ALLOW_THREADS;
rv = xmlSecDSigCtxSign(ctx->handle, node->_c_node);
rv = xmlSecDSigCtxSign(ctx->handle, target);
PYXMLSEC_DUMP(xmlSecDSigCtxDebugDump, ctx->handle);
Py_END_ALLOW_THREADS;
if (rv < 0) {
PyXmlSec_SetLastError("failed to sign");
if (PyXmlSec_LxmlShadowReflect(&shadow, rv, "failed to sign") < 0) {
goto ON_FAIL;
}
PYXMLSEC_DEBUGF("%p: sign - ok", self);
Expand All @@ -224,17 +245,25 @@ static PyObject* PyXmlSec_SignatureContextVerify(PyObject* self, PyObject* args,

PyXmlSec_SignatureContext* ctx = (PyXmlSec_SignatureContext*)self;
PyXmlSec_LxmlElementPtr node = NULL;
xmlNodePtr target;
PyXmlSec_LxmlShadow shadow;
int rv;

PYXMLSEC_DEBUGF("%p: verify - start", self);
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O&:verify", kwlist, PyXmlSec_LxmlElementConverter, &node)) {
goto ON_FAIL;
}

// Verification is read-only: whole-document shadow (with the registered
// IDs replayed) and no reflection at all — the copy is simply discarded.
if (PyXmlSec_LxmlShadowBeginDoc(&shadow, node, &target) < 0) {
goto ON_FAIL;
}
Py_BEGIN_ALLOW_THREADS;
rv = xmlSecDSigCtxVerify(ctx->handle, node->_c_node);
rv = xmlSecDSigCtxVerify(ctx->handle, target);
PYXMLSEC_DUMP(xmlSecDSigCtxDebugDump, ctx->handle);
Py_END_ALLOW_THREADS;
PyXmlSec_LxmlShadowDiscard(&shadow);

if (rv < 0) {
PyXmlSec_SetLastError("failed to verify");
Expand Down
Loading
Loading