Skip to content

dedup_batch collapses colliding values without reporting the collision (CVE-2026-23950) #620

Description

@raeq

Summary

dedup_batch() already has the shape a reservation table needs — it takes a collection and collapses values that reduce to the same thing — but it reports nothing about what collapsed:

disarm.dedup_batch(["groß.txt", "gross.txt", "other.txt"])
# ['gross.txt', 'gross.txt', 'other.txt']

Two distinct inputs became one value and the caller has no way to know it happened. That is CVE-2026-23950 exactly: node-tar's PathReservations failed to notice that groß.txt and gross.txt occupy one slot on a case-insensitive filesystem, so they were processed in parallel and the symlink guard was bypassed.

Why this is the shape that matters

Every other detector in disarm is a single-string predicate, and a collision is not a property of a single string. groß.txt is a perfectly good German filename. The question is only answerable about a set:

given these names, which pairs of them are the same name?

dedup_batch is the only public entry point that takes a set. It is currently a performance helper — "process each distinct value only once" — but the information it needs to do its job is precisely the information the caller needs for safety, and it is thrown away.

What a caller does today

It works with the public API, and that is worth saying plainly — this is an ergonomics gap, not a capability gap:

from disarm import canonicalize, fold_case

seen, collisions = {}, []
for name in ["groß.txt", "gross.txt", "GROSS.TXT", "other.txt"]:
    key = fold_case(canonicalize(name))
    if key in seen:
        collisions.append((seen[key], name, key))
    seen[key] = name

# [('groß.txt', 'gross.txt', 'gross.txt'), ('gross.txt', 'GROSS.TXT', 'gross.txt')]

The hard part is not the loop. It is knowing that fold_case is the right reducer, that str.casefold() would work but str.lower() would not, and that the class of characters involved has 2,345 members (see the sibling issue). A caller who reaches for str.lower() gets a check that silently misses ß.

Suggested shape

Something that returns the groups rather than the flattened list — for example a dedup_batch(..., report=True) mode, or a separate find_collisions(values, key=...), returning the sets of inputs that share a key. The choice of reducer should be the caller's, since the right key differs by use: a filesystem reservation wants fold_case, an account lookup wants search_key, a catalog wants catalog_key.

Whatever the surface, the property worth guaranteeing is that collapsing and reporting use the same reducer, so the answer cannot drift from the behaviour.

Related

Current state

Pinned in tests/test_cve_vectors.py::TestTarPathCollision, which asserts the key builders collide the pair and the canonicalizers deliberately do not. That row shows Detected by — in docs/security/cve-validation.md, and this is what would change it.

Refs #618, #619

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

Status
Done

Relationships

None yet

Development

No branches or pull requests

Issue actions