diff --git a/crates/arti-client/src/config.rs b/crates/arti-client/src/config.rs
index 80d5398f17749218e1701decd2cd672555d6ef74..98fe32936b2dea422a54c9605826735c2d5311bd 100644
--- a/crates/arti-client/src/config.rs
+++ b/crates/arti-client/src/config.rs
@@ -300,6 +300,7 @@ impl TryInto<dir::DirMgrConfig> for &TorClientConfig {
             schedule_config:     self.download_schedule  .clone(),
             cache_path:          self.storage.expand_cache_dir()?,
             override_net_params: self.override_net_params.clone(),
+            extensions:          Default::default(),
         })
     }
 }
diff --git a/crates/tor-dirmgr/Cargo.toml b/crates/tor-dirmgr/Cargo.toml
index f75a67ad8ddddf25e70dd1696947c433d833ee4e..ffd31afbe334293c65813765ac45b19c377b5d8d 100644
--- a/crates/tor-dirmgr/Cargo.toml
+++ b/crates/tor-dirmgr/Cargo.toml
@@ -16,6 +16,7 @@ mmap = ["memmap2"]
 static = ["rusqlite/bundled"]
 # (Incomplete) support for downloading and storing router descriptors
 routerdesc = ["tor-dirclient/routerdesc"]
+dirfilter = []
 
 # Enable experimental APIs that are not yet officially supported.
 #
diff --git a/crates/tor-dirmgr/src/config.rs b/crates/tor-dirmgr/src/config.rs
index 54f037ae4633f13a659b4fa57f4fa768ad0977fb..5ab6f26e31f1f38dc25ef9f77768b44dae9e97a9 100644
--- a/crates/tor-dirmgr/src/config.rs
+++ b/crates/tor-dirmgr/src/config.rs
@@ -199,6 +199,12 @@ pub struct DirMgrConfig {
     /// immediately. Users should _not_ assume that the effect of changing this
     /// option will always be delayed.)
     pub override_net_params: netstatus::NetParams<i32>,
+
+    /// Extra fields for extension purposes.
+    ///
+    /// These are kept in a separate type so that the type can be marked as
+    /// `non_exhaustive` and used for optional features.
+    pub extensions: DirMgrExtensions,
 }
 
 impl DirMgrConfig {
@@ -252,6 +258,7 @@ impl DirMgrConfig {
             },
             schedule_config: new_config.schedule_config.clone(),
             override_net_params: new_config.override_net_params.clone(),
+            extensions: new_config.extensions.clone(),
         }
     }
 
@@ -265,6 +272,15 @@ impl DirMgrConfig {
     }
 }
 
+/// Optional extensions for configuring
+#[derive(Debug, Clone, Default)]
+#[non_exhaustive]
+pub struct DirMgrExtensions {
+    /// A filter to be used when installing new directory objects.
+    #[cfg(feature = "dirfilter")]
+    pub filter: Option<crate::filter::DynFilter>,
+}
+
 impl DownloadScheduleConfig {
     /// Return configuration for retrying our entire bootstrap
     /// operation at startup.
diff --git a/crates/tor-dirmgr/src/filter.rs b/crates/tor-dirmgr/src/filter.rs
new file mode 100644
index 0000000000000000000000000000000000000000..99d370fd9ec7cacbadbf1661e57109d7ed7def40
--- /dev/null
+++ b/crates/tor-dirmgr/src/filter.rs
@@ -0,0 +1,83 @@
+//! A filtering mechanism for directory objects.
+//!
+//! This module and its members are only available when `tor-dirmgr` is built
+//! with the `dirfilter` feature.
+//!
+//! This is unstable code, currently used for testing only.  It might go away in
+//! future versions, or its API might change completely. There are no semver
+//! guarantees.
+
+use std::sync::Arc;
+
+use crate::Result;
+use tor_netdoc::doc::{microdesc::Microdesc, netstatus::UncheckedMdConsensus};
+
+/// An object that can filter directory documents before they're handled.
+///
+/// Instances of DirFilter can be used for testing, to modify directory data
+/// on-the-fly.
+pub trait DirFilter {
+    /// Modify `consensus` in an unspecified way.
+    fn filter_consensus(&self, consensus: UncheckedMdConsensus) -> Result<UncheckedMdConsensus>;
+    /// Modify `md` in an unspecified way.
+    fn filter_md(&self, md: Microdesc) -> Result<Microdesc>;
+}
+
+/// A dynamic [`DirFilter`] instance.
+#[derive(Clone)]
+pub struct DynFilter {
+    /// A reference to the DirFilter object
+    filter: Arc<dyn DirFilter + Send + Sync>,
+}
+
+impl From<&Option<DynFilter>> for DynFilter {
+    fn from(option: &Option<DynFilter>) -> Self {
+        option.as_ref().map(Clone::clone).unwrap_or_default()
+    }
+}
+
+impl Default for DynFilter {
+    fn default() -> Self {
+        DynFilter::new(NilFilter)
+    }
+}
+
+impl DynFilter {
+    /// Wrap `filter` as a [`DynFilter`]
+    pub fn new<T>(filter: T) -> Self
+    where
+        T: DirFilter + Send + Sync + 'static,
+    {
+        DynFilter {
+            filter: Arc::new(filter),
+        }
+    }
+}
+
+impl DirFilter for DynFilter {
+    fn filter_consensus(&self, consensus: UncheckedMdConsensus) -> Result<UncheckedMdConsensus> {
+        self.filter.filter_consensus(consensus)
+    }
+
+    fn filter_md(&self, md: Microdesc) -> Result<Microdesc> {
+        self.filter.filter_md(md)
+    }
+}
+
+impl std::fmt::Debug for DynFilter {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("DynFilter").finish_non_exhaustive()
+    }
+}
+
+/// A [`DirFilter`] that does nothing.
+struct NilFilter;
+
+impl DirFilter for NilFilter {
+    fn filter_consensus(&self, consensus: UncheckedMdConsensus) -> Result<UncheckedMdConsensus> {
+        Ok(consensus)
+    }
+    fn filter_md(&self, md: Microdesc) -> Result<Microdesc> {
+        Ok(md)
+    }
+}
diff --git a/crates/tor-dirmgr/src/lib.rs b/crates/tor-dirmgr/src/lib.rs
index 5c966181ce3eb8345607064d18026c1bdd4bb5b1..08695588d111bab730e9923cd2eb27ae0af90875 100644
--- a/crates/tor-dirmgr/src/lib.rs
+++ b/crates/tor-dirmgr/src/lib.rs
@@ -66,6 +66,9 @@ mod shared_ref;
 mod state;
 mod storage;
 
+#[cfg(feature = "dirfilter")]
+pub mod filter;
+
 use crate::docid::{CacheUsage, ClientRequest, DocQuery};
 #[cfg(not(feature = "experimental-api"))]
 use crate::shared_ref::SharedMutArc;
@@ -222,6 +225,10 @@ pub struct DirMgr<R: Runtime> {
     ///
     /// (In offline mode, this does nothing.)
     bootstrap_started: AtomicBool,
+
+    /// A filter that gets applied to directory objects before we use them.
+    #[cfg(feature = "dirfilter")]
+    filter: filter::DynFilter,
 }
 
 /// RAII guard to reset an AtomicBool on drop.
@@ -702,6 +709,8 @@ impl<R: Runtime> DirMgr<R> {
         let receive_status = DirBootstrapEvents {
             inner: receive_status,
         };
+        #[cfg(feature = "dirfilter")]
+        let filter = (&config.extensions.filter).into();
 
         Ok(DirMgr {
             config: config.into(),
@@ -714,6 +723,8 @@ impl<R: Runtime> DirMgr<R> {
             runtime,
             offline,
             bootstrap_started: AtomicBool::new(false),
+            #[cfg(feature = "dirfilter")]
+            filter,
         })
     }
 
diff --git a/crates/tor-dirmgr/src/state.rs b/crates/tor-dirmgr/src/state.rs
index 214e5681b04f333af9572b6f69bef8660e928cfc..e6fff3ffe98b5ac71b3b80ac32e811ce6a82530d 100644
--- a/crates/tor-dirmgr/src/state.rs
+++ b/crates/tor-dirmgr/src/state.rs
@@ -23,6 +23,8 @@ use tracing::{info, warn};
 
 use crate::event::{DirStatus, DirStatusInner};
 
+#[cfg(feature = "dirfilter")]
+use crate::filter::DirFilter;
 use crate::storage::{DynStore, EXPIRATION_DEFAULTS};
 use crate::{
     docmeta::{AuthCertMeta, ConsensusMeta},
@@ -84,6 +86,10 @@ pub(crate) trait WriteNetDir: 'static + Sync + Send {
     /// testing it is helpful to be able to mock our our current view
     /// of the time.
     fn now(&self) -> SystemTime;
+
+    /// Return the currently configured DynFilter for this state.
+    #[cfg(feature = "dirfilter")]
+    fn filter(&self) -> crate::filter::DynFilter;
 }
 
 impl<R: Runtime> WriteNetDir for crate::DirMgr<R> {
@@ -108,6 +114,11 @@ impl<R: Runtime> WriteNetDir for crate::DirMgr<R> {
     fn now(&self) -> SystemTime {
         SystemTime::now()
     }
+
+    #[cfg(feature = "dirfilter")]
+    fn filter(&self) -> crate::filter::DynFilter {
+        self.filter.clone()
+    }
 }
 
 /// Initial state: fetching or loading a consensus directory.
@@ -278,6 +289,12 @@ impl<DM: WriteNetDir> GetConsensusState<DM> {
         let (consensus_meta, unvalidated) = {
             let (signedval, remainder, parsed) =
                 MdConsensus::parse(text).map_err(|e| Error::from_netdoc(source.clone(), e))?;
+            #[cfg(feature = "dirfilter")]
+            let parsed = if let Some(wd) = Weak::upgrade(&self.writedir) {
+                wd.filter().filter_consensus(parsed)?
+            } else {
+                parsed
+            };
             let now = current_time(&self.writedir)?;
             if let Ok(timely) = parsed.check_valid_at(&now) {
                 let meta = ConsensusMeta::from_unvalidated(signedval, remainder, &timely);
@@ -651,6 +668,14 @@ impl<DM: WriteNetDir> GetMicrodescsState<DM> {
     where
         I: IntoIterator<Item = Microdesc>,
     {
+        #[cfg(feature = "dirfilter")]
+        let mds: Vec<Microdesc> = if let Some(wd) = Weak::upgrade(&self.writedir) {
+            mds.into_iter()
+                .filter_map(|m| wd.filter().filter_md(m).ok())
+                .collect()
+        } else {
+            mds.into_iter().collect()
+        };
         if let Some(p) = &mut self.partial {
             for md in mds {
                 self.newly_listed.push(*md.digest());
@@ -1033,6 +1058,10 @@ mod test {
         fn now(&self) -> SystemTime {
             self.now
         }
+        #[cfg(feature = "dirfilter")]
+        fn filter(&self) -> crate::filter::DynFilter {
+            Default::default()
+        }
     }
 
     // Test data