Skip to content

Prefix relativization silently corrupts non-matching keys (boundary violation) #82

Description

@thorwhalen

Summary

mk_relative_path_store, KeyCodecs.prefixed and prefixless_view all relativize keys by unguarded slicing. Keys that do not start with the prefix are not filtered out — they are silently corrupted into plausible-looking keys that address a different object, and which are writable.

Found while redesigning s3dol (i2mint/s3dol), where a prefix is used as a tenant/app boundary. Verified against dol 0.3.58.

Repro

from dol import KeyCodecs, Pipe, filt_iter, mk_relative_path_store

base = {'a/b': 1, 'a/c': 2, 'z': 3, 'ab/x': 4}

sorted(KeyCodecs.prefixed('a/')(dict(base)))          # ['', '/x', 'b', 'c']   <- CORRUPT
# mk_relative_path_store(cls, prefix_attr='prefix')   # ['', '/x', 'b', 'c']   <- CORRUPT
# prefixless_view(store, prefix='a/')                 # ['', '/x', 'b', 'c']   <- CORRUPT

sorted(Pipe(filt_iter.prefixes('a/'), KeyCodecs.prefixed('a/'))(dict(base)))   # ['b', 'c']  OK

Two distinct corruptions:

  • the non-matching key z becomes '' — and w[''] then raises KeyError: 'a/'
  • the sibling key ab/x becomes /x

Why it matters

For a store scoped to logs/, a neighbouring object logs2/2026.txt surfaces as the key 2/2026.txt. It reads, and it writes — so a store scoped to one prefix can silently read and overwrite objects under another. Wherever a prefix is used as an isolation boundary (per-tenant, per-app, per-environment), that is a boundary violation produced by the storage layer itself, with no error anywhere.

It also makes the natural fix for a hand-rolled id[len(prefix):] not a fix — s3dol had exactly that bug and "use dol's canonical mechanism" would have reproduced it under a clean-architecture label.

Suggested fix

A strict=True mode on the prefix machinery (candidate default in a future major) where a key outside the prefix raises rather than being sliced, plus a property test:

∀ k in-scope:      key_of_id(id_of_key(k)) == k
∀ i out-of-scope:  key_of_id(i) raises        # never returns a corrupted key

Until then, the only safe composition is Pipe(filt_iter.prefixes(p), KeyCodecs.prefixed(p)) — i.e. filt_iter.prefixes is a correctness requirement, not an optimization — and it is worth documenting that prominently, because the ordering is not obvious and reversing it yields a silently empty store.

Prefix normalization matters too: with prefix='logs' (no trailing delimiter) even the safe composition exposes logs2/.... Normalizing to prefix.strip(sep) + sep should probably happen inside the machinery rather than being left to each caller.

Related, same area

  1. filter_prefixes regex grouping. dol.trans.filter_prefixes(['logs/', 'tmp/']) compiles to ^logs/|tmp/, which parses as (^logs/)|(tmp/) — so 'zzz/tmp/c' matches. Needs ^(?:logs/|tmp/).
  2. _filt_iter assigns __len__ unconditionally (dol/trans.py, no hasattr guard), so wrapping a class that deliberately omits __len__ (because counting means unbounded paginated listing over a remote backend) silently resurrects it — and restores the double-listing that omitting it was meant to avoid, since list() takes a length hint from __len__.

Happy to send a PR for any/all of these.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions