Repository: github/codeql
Pull request #18960: Rust: TaintedPath query
Author: login: aibaars
Labels: name: documentation
name: Python
name: no-change-note-required
description: This PR does not need a change note
name: ready-for-doc-review
description: This PR requires and is ready for review from the GitHub docs team.
name: Rust
description: Pull requests that update Rust code
Created: 2025-03-10T12:37:01Z | Merged: 2025-03-27T09:37:36Z



Diff:
diff --git a/python/ql/lib/semmle/python/Concepts.qll b/python/ql/lib/semmle/python/Concepts.qll
index 94d660d75108..27f622c7c868 100644
--- a/python/ql/lib/semmle/python/Concepts.qll
+++ b/python/ql/lib/semmle/python/Concepts.qll
@@ -181,7 +181,7 @@ module Path {
     }
   }
 
-  /** A data-flow node that checks that a path is safe to access. */
+  /** A data-flow node that checks that a path is safe to access in some way, for example by having a controlled prefix. */
   class SafeAccessCheck extends DataFlow::ExprNode {
     SafeAccessCheck() { this = DataFlow::BarrierGuard<safeAccessCheck/3>::getABarrierNode() }
   }
@@ -192,7 +192,7 @@ module Path {
 
   /** Provides a class for modeling new path safety checks. */
   module SafeAccessCheck {
-    /** A data-flow node that checks that a path is safe to access. */
+    /** A data-flow node that checks that a path is safe to access in some way, for example by having a controlled prefix. */
     abstract class Range extends DataFlow::GuardNode {
       /** Holds if this guard validates `node` upon evaluating to `branch`. */
       abstract predicate checks(ControlFlowNode node, boolean branch);
diff --git a/rust/ql/integration-tests/hello-project/summary.expected b/rust/ql/integration-tests/hello-project/summary.expected
index 3599902243cd..1dd49972c22b 100644
--- a/rust/ql/integration-tests/hello-project/summary.expected
+++ b/rust/ql/integration-tests/hello-project/summary.expected
@@ -15,7 +15,7 @@
 | Macro calls - resolved | 2 |
 | Macro calls - total | 2 |
 | Macro calls - unresolved | 0 |
-| Taint edges - number of edges | 1671 |
+| Taint edges - number of edges | 1674 |
 | Taint reach - nodes tainted | 0 |
 | Taint reach - per million nodes | 0 |
 | Taint sinks - cryptographic operations | 0 |
diff --git a/rust/ql/integration-tests/hello-workspace/summary.cargo.expected b/rust/ql/integration-tests/hello-workspace/summary.cargo.expected
index 3fbea6c46417..53f9c345b96d 100644
--- a/rust/ql/integration-tests/hello-workspace/summary.cargo.expected
+++ b/rust/ql/integration-tests/hello-workspace/summary.cargo.expected
@@ -15,7 +15,7 @@
 | Macro calls - resolved | 2 |
 | Macro calls - total | 2 |
 | Macro calls - unresolved | 0 |
-| Taint edges - number of edges | 1671 |
+| Taint edges - number of edges | 1674 |
 | Taint reach - nodes tainted | 0 |
 | Taint reach - per million nodes | 0 |
 | Taint sinks - cryptographic operations | 0 |
diff --git a/rust/ql/integration-tests/hello-workspace/summary.rust-project.expected b/rust/ql/integration-tests/hello-workspace/summary.rust-project.expected
index 3fbea6c46417..53f9c345b96d 100644
--- a/rust/ql/integration-tests/hello-workspace/summary.rust-project.expected
+++ b/rust/ql/integration-tests/hello-workspace/summary.rust-project.expected
@@ -15,7 +15,7 @@
 | Macro calls - resolved | 2 |
 | Macro calls - total | 2 |
 | Macro calls - unresolved | 0 |
-| Taint edges - number of edges | 1671 |
+| Taint edges - number of edges | 1674 |
 | Taint reach - nodes tainted | 0 |
 | Taint reach - per million nodes | 0 |
 | Taint sinks - cryptographic operations | 0 |
diff --git a/rust/ql/lib/codeql/rust/Concepts.qll b/rust/ql/lib/codeql/rust/Concepts.qll
index 4f25840165e7..723cde6913a8 100644
--- a/rust/ql/lib/codeql/rust/Concepts.qll
+++ b/rust/ql/lib/codeql/rust/Concepts.qll
@@ -8,6 +8,8 @@ private import codeql.rust.dataflow.DataFlow
 private import codeql.threatmodels.ThreatModels
 private import codeql.rust.Frameworks
 private import codeql.rust.dataflow.FlowSource
+private import codeql.rust.controlflow.ControlFlowGraph as Cfg
+private import codeql.rust.controlflow.CfgNodes as CfgNodes
 
 /**
  * A data flow source for a specific threat-model.
@@ -264,3 +266,38 @@ module Cryptography {
 
   class CryptographicAlgorithm = SC::CryptographicAlgorithm;
 }
+
+/** Provides classes for modeling path-related APIs. */
+module Path {
+  final class PathNormalization = PathNormalization::Range;
+
+  /** Provides a class for modeling new path normalization APIs. */
+  module PathNormalization {
+    /**
+     * A data-flow node that performs path normalization. This is often needed in order
+     * to safely access paths.
+     */
+    abstract class Range extends DataFlow::Node {
+      /** Gets an argument to this path normalization that is interpreted as a path. */
+      abstract DataFlow::Node getPathArg();
+    }
+  }
+
+  /** A data-flow node that checks that a path is safe to access in some way, for example by having a controlled prefix. */
+  class SafeAccessCheck extends DataFlow::ExprNode {
+    SafeAccessCheck() { this = DataFlow::BarrierGuard<safeAccessCheck/3>::getABarrierNode() }
+  }
+
+  private predicate safeAccessCheck(CfgNodes::AstCfgNode g, Cfg::CfgNode node, boolean branch) {
+    g.(SafeAccessCheck::Range).checks(node, branch)
+  }
+
+  /** Provides a class for modeling new path safety checks. */
+  module SafeAccessCheck {
+    /** A data-flow node that checks that a path is safe to access in some way, for example by having a controlled prefix. */
+    abstract class Range extends CfgNodes::AstCfgNode {
+      /** Holds if this guard validates `node` upon evaluating to `branch`. */
+      abstract predicate checks(Cfg::CfgNode node, boolean branch);
+    }
+  }
+}
diff --git a/rust/ql/lib/codeql/rust/Frameworks.qll b/rust/ql/lib/codeql/rust/Frameworks.qll
index daa96538e21f..0e91ed427ba4 100644
--- a/rust/ql/lib/codeql/rust/Frameworks.qll
+++ b/rust/ql/lib/codeql/rust/Frameworks.qll
@@ -3,5 +3,7 @@
  */
 
 private import codeql.rust.frameworks.rustcrypto.RustCrypto
+private import codeql.rust.frameworks.Poem
 private import codeql.rust.frameworks.Sqlx
 private import codeql.rust.frameworks.stdlib.Clone
+private import codeql.rust.frameworks.stdlib.Stdlib
diff --git a/rust/ql/lib/codeql/rust/dataflow/DataFlow.qll b/rust/ql/lib/codeql/rust/dataflow/DataFlow.qll
index 3cb4dd9a982f..e6b7cdfeb50e 100644
--- a/rust/ql/lib/codeql/rust/dataflow/DataFlow.qll
+++ b/rust/ql/lib/codeql/rust/dataflow/DataFlow.qll
@@ -8,6 +8,8 @@ private import codeql.dataflow.DataFlow
 private import internal.DataFlowImpl as DataFlowImpl
 private import internal.Node as Node
 private import internal.Content as Content
+private import codeql.rust.controlflow.ControlFlowGraph as Cfg
+private import codeql.rust.controlflow.CfgNodes as CfgNodes
 
 /**
  * Provides classes for performing local (intra-procedural) and global
@@ -16,6 +18,8 @@ private import internal.Content as Content
 module DataFlow {
   final class Node = Node::NodePublic;
 
+  final class ExprNode = Node::ExprNode;
+
   /**
    * The value of a parameter at function entry, viewed as a node in a data
    * flow graph.
@@ -56,4 +60,31 @@ module DataFlow {
   predicate localFlow(Node::Node source, Node::Node sink) { localFlowStep*(source, sink) }
 
   import DataFlowMake<Location, DataFlowImpl::RustDataFlow>
+
+  /**
+   * Holds if the guard `g` validates the expression `e` upon evaluating to `v`.
+   *
+   * The expression `e` is expected to be a syntactic part of the guard `g`.
+   * For example, the guard `g` might be a call `isSafe(x)` and the expression `e`
+   * the argument `x`.
+   */
+  signature predicate guardChecksSig(CfgNodes::AstCfgNode g, Cfg::CfgNode e, boolean branch);
+
+  /**
+   * Provides a set of barrier nodes for a guard that validates an expression.
+   *
+   * This is expected to be used in `isBarrier`/`isSanitizer` definitions
+   * in data flow and taint tracking.
+   */
+  module BarrierGuard<guardChecksSig/3 guardChecks> {
+    private import internal.DataFlowImpl::SsaFlow as SsaFlow
+    private import internal.SsaImpl as SsaImpl
+
+    /** Gets a node that is safely guarded by the given guard check. */
+    pragma[nomagic]
+    Node getABarrierNode() {
+      SsaFlow::asNode(result) =
+        SsaImpl::DataFlowIntegration::BarrierGuard<guardChecks/3>::getABarrierNode()
+    }
+  }
 }
diff --git a/rust/ql/lib/codeql/rust/dataflow/internal/Content.qll b/rust/ql/lib/codeql/rust/dataflow/internal/Content.qll
index f9ad0a02cb71..d67085c62f83 100644
--- a/rust/ql/lib/codeql/rust/dataflow/internal/Content.qll
+++ b/rust/ql/lib/codeql/rust/dataflow/internal/Content.qll
@@ -214,6 +214,41 @@ final class SingletonContentSet extends ContentSet, TSingletonContentSet {
   override Content getAReadContent() { result = c }
 }
 
+/**
+ * A step in a flow summary defined using `OptionalStep[name]`. An `OptionalStep` is "opt-in", which means
+ * that by default the step is not present in the flow summary and needs to be explicitly enabled by defining
+ * an additional flow step.
+ */
+final class OptionalStep extends ContentSet, TOptionalStep {
+  override string toString() {
+    exists(string name |
+      this = TOptionalStep(name) and
+      result = "OptionalStep[" + name + "]"
+    )
+  }
+
+  override Content getAStoreContent() { none() }
+
+  override Content getAReadContent() { none() }
+}
+
+/**
+ * A step in a flow summary defined using `OptionalBarrier[name]`. An `OptionalBarrier` is "opt-out", by default
+ * data can flow freely through the step. Flow through the step can be explicity blocked by defining its node as a barrier.
+ */
+final class OptionalBarrier extends ContentSet, TOptionalBarrier {
+  override string toString() {
+    exists(string name |
+      this = TOptionalBarrier(name) and
+      result = "OptionalBarrier[" + name + "]"
+    )
+  }
+
+  override Content getAStoreContent() { none() }
+
+  override Content getAReadContent() { none() }
+}
+
 private import codeql.rust.internal.CachedStages
 
 cached
diff --git a/rust/ql/lib/codeql/rust/dataflow/internal/DataFlowImpl.qll b/rust/ql/lib/codeql/rust/dataflow/internal/DataFlowImpl.qll
index b589fe4ad6f9..0045279c615a 100644
--- a/rust/ql/lib/codeql/rust/dataflow/internal/DataFlowImpl.qll
+++ b/rust/ql/lib/codeql/rust/dataflow/internal/DataFlowImpl.qll
@@ -581,6 +581,12 @@ module RustDataFlow implements InputSig<Location> {
     model = ""
     or
     LocalFlow::flowSummaryLocalStep(nodeFrom, nodeTo, model)
+    or
+    // Add flow through optional barriers. This step is then blocked by the barrier for queries that choose to use the barrier.
+    FlowSummaryImpl::Private::Steps::summaryReadStep(nodeFrom
+          .(Node::FlowSummaryNode)
+          .getSummaryNode(), TOptionalBarrier(_), nodeTo.(Node::FlowSummaryNode).getSummaryNode()) and
+    model = ""
   }
 
   /**
@@ -710,7 +716,17 @@ module RustDataFlow implements InputSig<Location> {
     )
     or
     FlowSummaryImpl::Private::Steps::summaryReadStep(node1.(FlowSummaryNode).getSummaryNode(), cs,
-      node2.(FlowSummaryNode).getSummaryNode())
+      node2.(FlowSummaryNode).getSummaryNode()) and
+    not isSpecialContentSet(cs)
+  }
+
+  /**
+   * Holds if `cs` is used to encode a special operation as a content component, but should not
+   * be treated as an ordinary content component.
+   */
+  private predicate isSpecialContentSet(ContentSet cs) {
+    cs instanceof TOptionalStep or
+    cs instanceof TOptionalBarrier
   }
 
   pragma[nomagic]
@@ -807,7 +823,8 @@ module RustDataFlow implements InputSig<Location> {
     storeContentStep(node1, cs.(SingletonContentSet).getContent(), node2)
     or
     FlowSummaryImpl::Private::Steps::summaryStoreStep(node1.(FlowSummaryNode).getSummaryNode(), cs,
-      node2.(FlowSummaryNode).getSummaryNode())
+      node2.(FlowSummaryNode).getSummaryNode()) and
+    not isSpecialContentSet(cs)
   }
 
   /**
@@ -1093,7 +1110,14 @@ private module Cached {
   newtype TReturnKind = TNormalReturnKind()
 
   cached
-  newtype TContentSet = TSingletonContentSet(Content c)
+  newtype TContentSet =
+    TSingletonContentSet(Content c) or
+    TOptionalStep(string name) {
+      name = any(FlowSummaryImpl::Private::AccessPathToken tok).getAnArgument("OptionalStep")
+    } or
+    TOptionalBarrier(string name) {
+      name = any(FlowSummaryImpl::Private::AccessPathToken tok).getAnArgument("OptionalBarrier")
+    }
 
   /** Holds if `n` is a flow source of kind `kind`. */
   cached
@@ -1102,6 +1126,27 @@ private module Cached {
   /** Holds if `n` is a flow sink of kind `kind`. */
   cached
   predicate sinkNode(Node n, string kind) { n.(FlowSummaryNode).isSink(kind, _) }
+
+  /**
+   * A step in a flow summary defined using `OptionalStep[name]`. An `OptionalStep` is "opt-in", which means
+   * that by default the step is not present in the flow summary and needs to be explicitly enabled by defining
+   * an additional flow step.
+   */
+  cached
+  predicate optionalStep(Node node1, string name, Node node2) {
+    FlowSummaryImpl::Private::Steps::summaryReadStep(node1.(FlowSummaryNode).getSummaryNode(),
+      TOptionalStep(name), node2.(FlowSummaryNode).getSummaryNode())
+  }
+
+  /**
+   * A step in a flow summary defined using `OptionalBarrier[name]`. An `OptionalBarrier` is "opt-out", by default
+   * data can flow freely through the step. Flow through the step can be explicity blocked by defining its node as a barrier.
+   */
+  cached
+  predicate optionalBarrier(Node node, string name) {
+    FlowSummaryImpl::Private::Steps::summaryReadStep(_, TOptionalBarrier(name),
+      node.(FlowSummaryNode).getSummaryNode())
+  }
 }
 
 import Cached
diff --git a/rust/ql/lib/codeql/rust/dataflow/internal/FlowSummaryImpl.qll b/rust/ql/lib/codeql/rust/dataflow/internal/FlowSummaryImpl.qll
index 31c5b5b01aaa..97c04517b357 100644
--- a/rust/ql/lib/codeql/rust/dataflow/internal/FlowSummaryImpl.qll
+++ b/rust/ql/lib/codeql/rust/dataflow/internal/FlowSummaryImpl.qll
@@ -105,6 +105,10 @@ module Input implements InputSig<Location, RustDataFlow> {
       c = TFutureContent() and
       arg = ""
     )
+    or
+    cs = TOptionalStep(arg) and result = "OptionalStep"
+    or
+    cs = TOptionalBarrier(arg) and result = "OptionalBarrier"
   }
 
   string encodeReturn(ReturnKind rk, string arg) { none() }
diff --git a/rust/ql/lib/codeql/rust/dataflow/internal/TaintTrackingImpl.qll b/rust/ql/lib/codeql/rust/dataflow/internal/TaintTrackingImpl.qll
index 7cef3b58f554..8e0bd7947201 100644
--- a/rust/ql/lib/codeql/rust/dataflow/internal/TaintTrackingImpl.qll
+++ b/rust/ql/lib/codeql/rust/dataflow/internal/TaintTrackingImpl.qll
@@ -69,7 +69,9 @@ module RustTaintTracking implements InputSig<Location, RustDataFlow> {
     exists(Content c | c = cs.(SingletonContentSet).getContent() |
       c instanceof ElementContent or
       c instanceof ReferenceContent
-    )
+    ) and
+    // Optional steps are added through isAdditionalFlowStep but we don't want the implicit reads
+    not optionalStep(node, _, _)
   }
 
   /**
diff --git a/rust/ql/lib/codeql/rust/frameworks/Poem.qll b/rust/ql/lib/codeql/rust/frameworks/Poem.qll
new file mode 100644
index 000000000000..66c01a415a1c
--- /dev/null
+++ b/rust/ql/lib/codeql/rust/frameworks/Poem.qll
@@ -0,0 +1,20 @@
+/**
+ * Provides modeling for the `Poem` library.
+ */
+
+private import rust
+private import codeql.rust.Concepts
+private import codeql.rust.dataflow.DataFlow
+
+/**
+ * Parameters of a handler function
+ */
+private class PoemHandlerParam extends RemoteSource::Range {
+  PoemHandlerParam() {
+    exists(TupleStructPat param |
+      param.getResolvedPath() = ["crate::web::query::Query", "crate::web::path::Path"]
+    |
+      this.asPat().getPat() = param.getAField()
+    )
+  }
+}
diff --git a/rust/ql/lib/codeql/rust/frameworks/stdlib/Stdlib.qll b/rust/ql/lib/codeql/rust/frameworks/stdlib/Stdlib.qll
new file mode 100644
index 000000000000..9fc0e70833bc
--- /dev/null
+++ b/rust/ql/lib/codeql/rust/frameworks/stdlib/Stdlib.qll
@@ -0,0 +1,23 @@
+/**
+ * Provides classes modeling security-relevant aspects of the standard libraries.
+ */
+
+private import rust
+private import codeql.rust.Concepts
+private import codeql.rust.controlflow.ControlFlowGraph as Cfg
+private import codeql.rust.controlflow.CfgNodes as CfgNodes
+private import codeql.rust.dataflow.DataFlow
+
+/**
+ * A call to the `starts_with` method on a `Path`.
+ */
+private class StartswithCall extends Path::SafeAccessCheck::Range, CfgNodes::MethodCallExprCfgNode {
+  StartswithCall() {
+    this.getAstNode().(Resolvable).getResolvedPath() = "<crate::path::Path>::starts_with"
+  }
+
+  override predicate checks(Cfg::CfgNode e, boolean branch) {
+    e = this.getReceiver() and
+    branch = true
+  }
+}
diff --git a/rust/ql/lib/codeql/rust/frameworks/stdlib/fs.model.yml b/rust/ql/lib/codeql/rust/frameworks/stdlib/fs.model.yml
new file mode 100644
index 000000000000..c7fa72793306
--- /dev/null
+++ b/rust/ql/lib/codeql/rust/frameworks/stdlib/fs.model.yml
@@ -0,0 +1,46 @@
+extensions:
+  - addsTo:
+      pack: codeql/rust-all
+      extensible: sourceModel
+    data: []
+  - addsTo:
+      pack: codeql/rust-all
+      extensible: sinkModel
+    data:
+      - ["lang:std", "crate::fs::copy", "Argument[0]", "path-injection", "manual"]
+      - ["lang:std", "crate::fs::copy", "Argument[1]", "path-injection", "manual"]
+      - ["lang:std", "crate::fs::create_dir", "Argument[0]", "path-injection", "manual"]
+      - ["lang:std", "crate::fs::create_dir_all", "Argument[0]", "path-injection", "manual"]
+      - ["lang:std", "crate::fs::hard_link", "Argument[0]", "path-injection", "manual"]
+      - ["lang:std", "crate::fs::hard_link", "Argument[1]", "path-injection", "manual"]
+      - ["lang:std", "crate::fs::metadata", "Argument[0]", "path-injection", "manual"]
+      - ["lang:std", "crate::fs::read", "Argument[0]", "path-injection", "manual"]
+      - ["lang:std", "crate::fs::read_dir", "Argument[0]", "path-injection", "manual"]
+      - ["lang:std", "crate::fs::read_link", "Argument[0]", "path-injection", "manual"]
+      - ["lang:std", "crate::fs::read_to_string", "Argument[0]", "path-injection", "manual"]
+      - ["lang:std", "crate::fs::remove_dir", "Argument[0]", "path-injection", "manual"]
+      - ["lang:std", "crate::fs::remove_dir_all", "Argument[0]", "path-injection", "manual"]
+      - ["lang:std", "crate::fs::remove_file", "Argument[0]", "path-injection", "manual"]
+      - ["lang:std", "crate::fs::rename", "Argument[0]", "path-injection", "manual"]
+      - ["lang:std", "crate::fs::rename", "Argument[1]", "path-injection", "manual"]
+      - ["lang:std", "crate::fs::set_permissions", "Argument[0]", "path-injection", "manual"]
+      - ["lang:std", "crate::fs::soft_link", "Argument[0]", "path-injection", "manual"]
+      - ["lang:std", "crate::fs::soft_link", "Argument[1]", "path-injection", "manual"]
+      - ["lang:std", "crate::fs::symlink_metadata", "Argument[0]", "path-injection", "manual"]
+      - ["lang:std", "crate::fs::write", "Argument[0]", "path-injection", "manual"]
+      - ["lang:std", "<crate::fs::DirBuilder>::create", "Argument[0]", "path-injection", "manual"]
+      - ["lang:std", "<crate::fs::File>::create", "Argument[0]", "path-injection", "manual"]
+      - ["lang:std", "<crate::fs::File>::create_buffered", "Argument[0]", "path-injection", "manual"]
+      - ["lang:std", "<crate::fs::File>::create_new", "Argument[0]", "path-injection", "manual"]
+      - ["lang:std", "<crate::fs::File>::open", "Argument[0]", "path-injection", "manual"]
+      - ["lang:std", "<crate::fs::File>::open_buffered", "Argument[0]", "path-injection", "manual"]
+
+  - addsTo:
+      pack: codeql/rust-all
+      extensible: summaryModel
+    data:
+      - ["lang:std", "<crate::path::PathBuf as crate::convert::From>::from", "Argument[0]", "ReturnValue", "taint", "manual"]
+      - ["lang:std", "<crate::path::Path>::join", "Argument[self]", "ReturnValue", "taint", "manual"]
+      - ["lang:std", "<crate::path::Path>::join", "Argument[0]", "ReturnValue", "taint", "manual"]
+      - ["lang:std", "<crate::path::Path>::canonicalize", "Argument[self].OptionalStep[normalize-path]", "ReturnValue.Field[crate::result::Result::Ok(0)]", "taint", "manual"]
+      - ["lang:std", "<crate::path::Path>::canonicalize", "Argument[self].OptionalBarrier[normalize-path]", "ReturnValue.Field[crate::result::Result::Ok(0)]", "taint", "manual"]
diff --git a/rust/ql/lib/codeql/rust/security/TaintedPathExtensions.qll b/rust/ql/lib/codeql/rust/security/TaintedPathExtensions.qll
new file mode 100644
index 000000000000..df8c43212b34
--- /dev/null
+++ b/rust/ql/lib/codeql/rust/security/TaintedPathExtensions.qll
@@ -0,0 +1,79 @@
+/** Provides classes and predicates to reason about path injection vulnerabilities. */
+
+import rust
+private import codeql.rust.controlflow.BasicBlocks
+private import codeql.rust.controlflow.ControlFlowGraph
+private import codeql.rust.dataflow.DataFlow
+private import codeql.rust.dataflow.TaintTracking
+private import codeql.rust.Concepts
+private import codeql.rust.dataflow.internal.DataFlowImpl
+private import codeql.rust.controlflow.ControlFlowGraph as Cfg
+private import codeql.rust.controlflow.CfgNodes as CfgNodes
+
+/**
+ * Provides default sources, sinks and barriers for detecting path injection
+ * vulnerabilities, as well as extension points for adding your own.
+ */
+module TaintedPath {
+  /**
+   * A data flow source for path injection vulnerabilities.
+   */
+  abstract class Source extends DataFlow::Node { }
+
+  /**
+   * A data flow sink for path injection vulnerabilities.
+   */
+  abstract class Sink extends DataFlow::Node { }
+
+  /**
+   * A barrier for path injection vulnerabilities.
+   */
+  abstract class Barrier extends DataFlow::Node { }
+
+  /**
+   * A sanitizer guard for path-traversal vulnerabilities.
+   */
+  class SanitizerGuard extends DataFlow::Node {
+    SanitizerGuard() { this = DataFlow::BarrierGuard<sanitizerGuard/3>::getABarrierNode() }
+  }
+
+  /**
+   * An active threat-model source, considered as a flow source.
+   */
+  private class ActiveThreatModelSourceAsSource extends Source, ActiveThreatModelSource { }
+
+  /** A sink for path-injection from model data. */
+  private class ModelsAsDataSinks extends Sink {
+    ModelsAsDataSinks() { sinkNode(this, "path-injection") }
+  }
+}
+
+private predicate sanitizerGuard(CfgNodes::AstCfgNode g, Cfg::CfgNode node, boolean branch) {
+  g.(SanitizerGuard::Range).checks(node, branch)
+}
+
+/** Provides a class for modeling new path safety checks. */
+module SanitizerGuard {
+  /** A data-flow node that checks that a path is safe to access. */
+  abstract class Range extends CfgNodes::AstCfgNode {
+    /** Holds if this guard validates `node` upon evaluating to `branch`. */
+    abstract predicate checks(Cfg::CfgNode node, boolean branch);
+  }
+}
+
+/**
+ * A check of the form `!strings.Contains(nd, "..")`, considered as a sanitizer guard for
+ * path traversal.
+ */
+private class DotDotCheck extends SanitizerGuard::Range, CfgNodes::MethodCallExprCfgNode {
+  DotDotCheck() {
+    this.getAstNode().(Resolvable).getResolvedPath() = "<str>::contains" and
+    this.getArgument(0).getAstNode().(LiteralExpr).getTextValue() =
+      ["\"..\"", "\"../\"", "\"..\\\""]
+  }
+
+  override predicate checks(Cfg::CfgNode e, boolean branch) {
+    e = this.getReceiver() and
+    branch = false
+  }
+}
diff --git a/rust/ql/src/queries/security/CWE-022/TaintedPath.qhelp b/rust/ql/src/queries/security/CWE-022/TaintedPath.qhelp
new file mode 100644
index 000000000000..8f74c60cfc29
--- /dev/null
+++ b/rust/ql/src/queries/security/CWE-022/TaintedPath.qhelp
@@ -0,0 +1,67 @@
+<!DOCTYPE qhelp PUBLIC
+  "-//Semmle//qhelp//EN"
+  "qhelp.dtd">
+<qhelp>
+<overview>
+<p>Accessing paths controlled by users can allow an attacker to access unexpected resources. This
+can result in sensitive information being revealed or deleted, or an attacker being able to influence
+behavior by modifying unexpected files.</p>
+
+<p>Paths that are naively constructed from data controlled by a user may be absolute paths, or may contain
+unexpected special characters such as "..". Such a path could point anywhere on the file system.</p>
+
+</overview>
+<recommendation>
+
+<p>Validate user input before using it to construct a file path.</p>
+
+<p>Common validation methods include checking that the normalized path is relative and does not contain
+any ".." components, or checking that the path is contained within a safe folder. The method you should use depends 
+on how the path is used in the application, and whether the path should be a single path component.
+</p>
+
+<p>If the path should be a single path component (such as a file name), you can check for the existence 
+of any path separators ("/" or "\"), or ".." sequences in the input, and reject the input if any are found.
+</p>
+
+<p>
+Note that removing "../" sequences is <i>not</i> sufficient, since the input could still contain a path separator
+followed by "..". For example, the input ".../...//" would still result in the string "../" if only "../" sequences
+are removed.
+</p>
+
+<p>Finally, the simplest (but most restrictive) option is to use an allow list of safe patterns and make sure that
+the user input matches one of these patterns.</p>
+
+</recommendation>
+<example>
+
+<p>In this example, a user-provided file name is read from a HTTP request and then used to access a file
+and send it back to the user. However, a malicious user could enter a file name anywhere on the file system,
+such as "/etc/passwd" or "../../../etc/passwd".</p>
+
+<sample src="examples/TaintedPath.rs" />
+
+<p>
+If the input should only be a file name, you can check that it doesn't contain any path separators or ".." sequences.
+</p>
+
+<sample src="examples/TaintedPathGoodNormalize.rs" />
+
+<p>
+If the input should be within a specific directory, you can check that the resolved path 
+is still contained within that directory.
+</p>
+
+<sample src="examples/TaintedPathGoodFolder.rs" />
+
+</example>
+<references>
+
+<li>
+OWASP:
+<a href="https://owasp.org/www-community/attacks/Path_Traversal">Path Traversal</a>.
+</li>
+
+</references>
+</qhelp>
diff --git a/rust/ql/src/queries/security/CWE-022/TaintedPath.ql b/rust/ql/src/queries/security/CWE-022/TaintedPath.ql
new file mode 100644
index 000000000000..fcc1c89ef662
--- /dev/null
+++ b/rust/ql/src/queries/security/CWE-022/TaintedPath.ql
@@ -0,0 +1,90 @@
+/**
+ * @name Uncontrolled data used in path expression
+ * @description Accessing paths influenced by users can allow an attacker to access unexpected resources.
+ * @kind path-problem
+ * @problem.severity error
+ * @security-severity 7.5
+ * @precision high
+ * @id rust/path-injection
+ * @tags security
+ *       external/cwe/cwe-022
+ *       external/cwe/cwe-023
+ *       external/cwe/cwe-036
+ *       external/cwe/cwe-073
+ *       external/cwe/cwe-099
+ */
+
+import rust
+import codeql.rust.dataflow.DataFlow
+import codeql.rust.dataflow.internal.DataFlowImpl as DataflowImpl
+import codeql.rust.dataflow.TaintTracking
+import codeql.rust.security.TaintedPathExtensions
+import TaintedPathFlow::PathGraph
+private import codeql.rust.Concepts
+
+newtype NormalizationState =
+  /** A state signifying that the file path has not been normalized. */
+  NotNormalized() or
+  /** A state signifying that the file path has been normalized, but not checked. */
+  NormalizedUnchecked()
+
+/**
+ * This configuration uses two flow states, `NotNormalized` and `NormalizedUnchecked`,
+ * to track the requirement that a file path must be first normalized and then checked
+ * before it is safe to use.
+ *
+ * At sources, paths are assumed not normalized. At normalization points, they change
+ * state to `NormalizedUnchecked` after which they can be made safe by an appropriate
+ * check of the prefix.
+ *
+ * Such checks are ineffective in the `NotNormalized` state.
+ */
+module TaintedPathConfig implements DataFlow::StateConfigSig {
+  class FlowState = NormalizationState;
+
+  predicate isSource(DataFlow::Node source, FlowState state) {
+    source instanceof TaintedPath::Source and state instanceof NotNormalized
+  }
+
+  predicate isSink(DataFlow::Node sink, FlowState state) {
+    sink instanceof TaintedPath::Sink and
+    (
+      state instanceof NotNormalized or
+      state instanceof NormalizedUnchecked
+    )
+  }
+
+  predicate isBarrier(DataFlow::Node node) {
+    node instanceof TaintedPath::Barrier or node instanceof TaintedPath::SanitizerGuard
+  }
+
+  predicate isBarrier(DataFlow::Node node, FlowState state) {
+    // Block `NotNormalized` paths here, since they change state to `NormalizedUnchecked`
+    (
+      node instanceof Path::PathNormalization or
+      DataflowImpl::optionalBarrier(node, "normalize-path")
+    ) and
+    state instanceof NotNormalized
+    or
+    node instanceof Path::SafeAccessCheck and
+    state instanceof NormalizedUnchecked
+  }
+
+  predicate isAdditionalFlowStep(
+    DataFlow::Node nodeFrom, FlowState stateFrom, DataFlow::Node nodeTo, FlowState stateTo
+  ) {
+    (
+      nodeFrom = nodeTo.(Path::PathNormalization).getPathArg() or
+      DataflowImpl::optionalStep(nodeFrom, "normalize-path", nodeTo)
+    ) and
+    stateFrom instanceof NotNormalized and
+    stateTo instanceof NormalizedUnchecked
+  }
+}
+
+module TaintedPathFlow = TaintTracking::GlobalWithState<TaintedPathConfig>;
+
+from TaintedPathFlow::PathNode source, TaintedPathFlow::PathNode sink
+where TaintedPathFlow::flowPath(source, sink)
+select sink.getNode(), source, sink, "This path depends on a $@.", source.getNode(),
+  "user-provided value"
diff --git a/rust/ql/src/queries/security/CWE-022/examples/TaintedPath.rs b/rust/ql/src/queries/security/CWE-022/examples/TaintedPath.rs
new file mode 100644
index 000000000000..fb0fdbbf6f55
--- /dev/null
+++ b/rust/ql/src/queries/security/CWE-022/examples/TaintedPath.rs
@@ -0,0 +1,9 @@
+use poem::{error::InternalServerError, handler, web::Query, Result};
+use std::{fs, path::PathBuf};
+
+#[handler]
+fn tainted_path_handler(Query(file_name): Query<String>) -> Result<String> {
+    let file_path = PathBuf::from(file_name);
+    // BAD: This could read any file on the filesystem.
+    fs::read_to_string(file_path).map_err(InternalServerError)
+}
diff --git a/rust/ql/src/queries/security/CWE-022/examples/TaintedPathGoodFolder.rs b/rust/ql/src/queries/security/CWE-022/examples/TaintedPathGoodFolder.rs
new file mode 100644
index 000000000000..8602ad4d5c39
--- /dev/null
+++ b/rust/ql/src/queries/security/CWE-022/examples/TaintedPathGoodFolder.rs
@@ -0,0 +1,14 @@
+use poem::{error::InternalServerError, handler, http::StatusCode, web::Query, Error, Result};
+use std::{env::home_dir, fs, path::PathBuf};
+
+#[handler]
+fn tainted_path_handler(Query(file_path): Query<String>) -> Result<String, Error> {
+    let public_path = home_dir().unwrap().join("public");
+    let file_path = public_path.join(PathBuf::from(file_path));
+    let file_path = file_path.canonicalize().unwrap();
+    // GOOD: ensure that the path stays within the public folder
+    if !file_path.starts_with(public_path) {
+        return Err(Error::from_status(StatusCode::BAD_REQUEST));
+    }
+    fs::read_to_string(file_path).map_err(InternalServerError)
+}
diff --git a/rust/ql/src/queries/security/CWE-022/examples/TaintedPathGoodNormalize.rs b/rust/ql/src/queries/security/CWE-022/examples/TaintedPathGoodNormalize.rs
new file mode 100644
index 000000000000..d36cb0e36585
--- /dev/null
+++ b/rust/ql/src/queries/security/CWE-022/examples/TaintedPathGoodNormalize.rs
@@ -0,0 +1,12 @@
+use poem::{error::InternalServerError, handler, http::StatusCode, web::Query, Error, Result};
+use std::{fs, path::PathBuf};
+
+#[handler]
+fn tainted_path_handler(Query(file_name): Query<String>) -> Result<String> {
+    // GOOD: ensure that the filename has no path separators or parent directory references
+    if file_name.contains("..") || file_name.contains("/") || file_name.contains("\\") {
+        return Err(Error::from_status(StatusCode::BAD_REQUEST));
+    }
+    let file_path = PathBuf::from(file_name);
+    fs::read_to_string(file_path).map_err(InternalServerError)
+}
diff --git a/rust/ql/test/library-tests/dataflow/local/DataFlowStep.expected b/rust/ql/test/library-tests/dataflow/local/DataFlowStep.expected
index 23270af042a6..f7633aed33e4 100644
--- a/rust/ql/test/library-tests/dataflow/local/DataFlowStep.expected
+++ b/rust/ql/test/library-tests/dataflow/local/DataFlowStep.expected
@@ -1,4 +1,5 @@
 localStep
+| file://:0:0:0:0 | [summary param] self in lang:std::_::<crate::path::Path>::canonicalize | file://:0:0:0:0 | [summary] read: Argument[self].OptionalBarrier[normalize-path] in lang:std::_::<crate::path::Path>::canonicalize |
 | main.rs:3:11:3:11 | [SSA] i | main.rs:4:12:4:12 | i |
 | main.rs:3:11:3:11 | i | main.rs:3:11:3:11 | [SSA] i |
 | main.rs:3:11:3:11 | i | main.rs:3:11:3:11 | i |
@@ -2165,6 +2166,7 @@ storeStep
 | file://:0:0:0:0 | [summary] to write: ReturnValue.Field[crate::result::Result::Ok(0)] in lang:core::_::<crate::result::Result>::or_else | Ok | file://:0:0:0:0 | [summary] to write: ReturnValue in lang:core::_::<crate::result::Result>::or_else |
 | file://:0:0:0:0 | [summary] to write: ReturnValue.Field[crate::result::Result::Ok(0)] in lang:core::_::<str>::parse | Ok | file://:0:0:0:0 | [summary] to write: ReturnValue in lang:core::_::<str>::parse |
 | file://:0:0:0:0 | [summary] to write: ReturnValue.Field[crate::result::Result::Ok(0)] in lang:std::_::<&[u8] as crate::io::BufRead>::fill_buf | Ok | file://:0:0:0:0 | [summary] to write: ReturnValue in lang:std::_::<&[u8] as crate::io::BufRead>::fill_buf |
+| file://:0:0:0:0 | [summary] to write: ReturnValue.Field[crate::result::Result::Ok(0)] in lang:std::_::<crate::path::Path>::canonicalize | Ok | file://:0:0:0:0 | [summary] to write: ReturnValue in lang:std::_::<crate::path::Path>::canonicalize |
 | file://:0:0:0:0 | [summary] to write: ReturnValue.Field[crate::result::Result::Ok(0)] in lang:std::_::<crate::sync::poison::condvar::Condvar>::wait | Ok | file://:0:0:0:0 | [summary] to write: ReturnValue in lang:std::_::<crate::sync::poison::condvar::Condvar>::wait |
 | file://:0:0:0:0 | [summary] to write: ReturnValue.Field[crate::result::Result::Ok(0)] in lang:std::_::<crate::sync::poison::condvar::Condvar>::wait_timeout | Ok | file://:0:0:0:0 | [summary] to write: ReturnValue in lang:std::_::<crate::sync::poison::condvar::Condvar>::wait_timeout |
 | file://:0:0:0:0 | [summary] to write: ReturnValue.Field[crate::result::Result::Ok(0)] in lang:std::_::<crate::sync::poison::condvar::Condvar>::wait_timeout_ms | Ok | file://:0:0:0:0 | [summary] to write: ReturnValue in lang:std::_::<crate::sync::poison::condvar::Condvar>::wait_timeout_ms |
diff --git a/rust/ql/test/query-tests/diagnostics/SummaryStats.expected b/rust/ql/test/query-tests/diagnostics/SummaryStats.expected
index aaf68558f09c..d34cd849069b 100644
--- a/rust/ql/test/query-tests/diagnostics/SummaryStats.expected
+++ b/rust/ql/test/query-tests/diagnostics/SummaryStats.expected
@@ -15,7 +15,7 @@
 | Macro calls - resolved | 8 |
 | Macro calls - total | 9 |
 | Macro calls - unresolved | 1 |
-| Taint edges - number of edges | 1671 |
+| Taint edges - number of edges | 1674 |
 | Taint reach - nodes tainted | 0 |
 | Taint reach - per million nodes | 0 |
 | Taint sinks - cryptographic operations | 0 |
diff --git a/rust/ql/test/query-tests/security/CWE-020/RegexInjection.expected b/rust/ql/test/query-tests/security/CWE-020/RegexInjection.expected
index e204b5a39264..430296c7c012 100644
--- a/rust/ql/test/query-tests/security/CWE-020/RegexInjection.expected
+++ b/rust/ql/test/query-tests/security/CWE-020/RegexInjection.expected
@@ -3,14 +3,14 @@
 edges
 | main.rs:4:9:4:16 | username | main.rs:5:25:5:44 | MacroExpr | provenance |  |
 | main.rs:4:20:4:32 | ...::var | main.rs:4:20:4:40 | ...::var(...) [Ok] | provenance | Src:MaD:62  |
-| main.rs:4:20:4:40 | ...::var(...) [Ok] | main.rs:4:20:4:66 | ... .unwrap_or(...) | provenance | MaD:1593 |
+| main.rs:4:20:4:40 | ...::var(...) [Ok] | main.rs:4:20:4:66 | ... .unwrap_or(...) | provenance | MaD:1625 |
 | main.rs:4:20:4:66 | ... .unwrap_or(...) | main.rs:4:9:4:16 | username | provenance |  |
 | main.rs:5:9:5:13 | regex | main.rs:6:26:6:30 | regex | provenance |  |
 | main.rs:5:17:5:45 | res | main.rs:5:25:5:44 | { ... } | provenance |  |
 | main.rs:5:25:5:44 | ...::format(...) | main.rs:5:17:5:45 | res | provenance |  |
 | main.rs:5:25:5:44 | ...::must_use(...) | main.rs:5:9:5:13 | regex | provenance |  |
-| main.rs:5:25:5:44 | MacroExpr | main.rs:5:25:5:44 | ...::format(...) | provenance | MaD:66 |
-| main.rs:5:25:5:44 | { ... } | main.rs:5:25:5:44 | ...::must_use(...) | provenance | MaD:3016 |
+| main.rs:5:25:5:44 | MacroExpr | main.rs:5:25:5:44 | ...::format(...) | provenance | MaD:98 |
+| main.rs:5:25:5:44 | { ... } | main.rs:5:25:5:44 | ...::must_use(...) | provenance | MaD:3048 |
 | main.rs:6:26:6:30 | regex | main.rs:6:25:6:30 | &regex | provenance |  |
 nodes
 | main.rs:4:9:4:16 | username | semmle.label | username |
diff --git a/rust/ql/test/query-tests/security/CWE-022/TaintedPath.expected b/rust/ql/test/query-tests/security/CWE-022/TaintedPath.expected
new file mode 100644
index 000000000000..d85eed692f6b
--- /dev/null
+++ b/rust/ql/test/query-tests/security/CWE-022/TaintedPath.expected
@@ -0,0 +1,55 @@
+#select
+| src/main.rs:10:5:10:22 | ...::read_to_string | src/main.rs:6:11:6:19 | file_name | src/main.rs:10:5:10:22 | ...::read_to_string | This path depends on a $@. | src/main.rs:6:11:6:19 | file_name | user-provided value |
+| src/main.rs:45:5:45:22 | ...::read_to_string | src/main.rs:37:11:37:19 | file_path | src/main.rs:45:5:45:22 | ...::read_to_string | This path depends on a $@. | src/main.rs:37:11:37:19 | file_path | user-provided value |
+| src/main.rs:59:5:59:22 | ...::read_to_string | src/main.rs:50:11:50:19 | file_path | src/main.rs:59:5:59:22 | ...::read_to_string | This path depends on a $@. | src/main.rs:50:11:50:19 | file_path | user-provided value |
+edges
+| src/main.rs:6:11:6:19 | file_name | src/main.rs:8:35:8:43 | file_name | provenance |  |
+| src/main.rs:8:9:8:17 | file_path | src/main.rs:10:24:10:32 | file_path | provenance |  |
+| src/main.rs:8:21:8:44 | ...::from(...) | src/main.rs:8:9:8:17 | file_path | provenance |  |
+| src/main.rs:8:35:8:43 | file_name | src/main.rs:8:21:8:44 | ...::from(...) | provenance | MaD:4 |
+| src/main.rs:10:24:10:32 | file_path | src/main.rs:10:5:10:22 | ...::read_to_string | provenance | MaD:1 Sink:MaD:1 |
+| src/main.rs:37:11:37:19 | file_path | src/main.rs:40:52:40:60 | file_path | provenance |  |
+| src/main.rs:40:9:40:17 | file_path | src/main.rs:45:24:45:32 | file_path | provenance |  |
+| src/main.rs:40:21:40:62 | public_path.join(...) | src/main.rs:40:9:40:17 | file_path | provenance |  |
+| src/main.rs:40:38:40:61 | ...::from(...) | src/main.rs:40:21:40:62 | public_path.join(...) | provenance | MaD:3 |
+| src/main.rs:40:52:40:60 | file_path | src/main.rs:40:38:40:61 | ...::from(...) | provenance | MaD:4 |
+| src/main.rs:45:24:45:32 | file_path | src/main.rs:45:5:45:22 | ...::read_to_string | provenance | MaD:1 Sink:MaD:1 |
+| src/main.rs:50:11:50:19 | file_path | src/main.rs:53:52:53:60 | file_path | provenance |  |
+| src/main.rs:53:9:53:17 | file_path | src/main.rs:54:21:54:44 | file_path.canonicalize(...) [Ok] | provenance | Config |
+| src/main.rs:53:21:53:62 | public_path.join(...) | src/main.rs:53:9:53:17 | file_path | provenance |  |
+| src/main.rs:53:38:53:61 | ...::from(...) | src/main.rs:53:21:53:62 | public_path.join(...) | provenance | MaD:3 |
+| src/main.rs:53:52:53:60 | file_path | src/main.rs:53:38:53:61 | ...::from(...) | provenance | MaD:4 |
+| src/main.rs:54:9:54:17 | file_path | src/main.rs:59:24:59:32 | file_path | provenance |  |
+| src/main.rs:54:21:54:44 | file_path.canonicalize(...) [Ok] | src/main.rs:54:21:54:53 | ... .unwrap(...) | provenance | MaD:2 |
+| src/main.rs:54:21:54:53 | ... .unwrap(...) | src/main.rs:54:9:54:17 | file_path | provenance |  |
+| src/main.rs:59:24:59:32 | file_path | src/main.rs:59:5:59:22 | ...::read_to_string | provenance | MaD:1 Sink:MaD:1 |
+models
+| 1 | Sink: lang:std; crate::fs::read_to_string; path-injection; Argument[0] |
+| 2 | Summary: lang:core; <crate::result::Result>::unwrap; Argument[self].Field[crate::result::Result::Ok(0)]; ReturnValue; value |
+| 3 | Summary: lang:std; <crate::path::Path>::join; Argument[0]; ReturnValue; taint |
+| 4 | Summary: lang:std; <crate::path::PathBuf as crate::convert::From>::from; Argument[0]; ReturnValue; taint |
+nodes
+| src/main.rs:6:11:6:19 | file_name | semmle.label | file_name |
+| src/main.rs:8:9:8:17 | file_path | semmle.label | file_path |
+| src/main.rs:8:21:8:44 | ...::from(...) | semmle.label | ...::from(...) |
+| src/main.rs:8:35:8:43 | file_name | semmle.label | file_name |
+| src/main.rs:10:5:10:22 | ...::read_to_string | semmle.label | ...::read_to_string |
+| src/main.rs:10:24:10:32 | file_path | semmle.label | file_path |
+| src/main.rs:37:11:37:19 | file_path | semmle.label | file_path |
+| src/main.rs:40:9:40:17 | file_path | semmle.label | file_path |
+| src/main.rs:40:21:40:62 | public_path.join(...) | semmle.label | public_path.join(...) |
+| src/main.rs:40:38:40:61 | ...::from(...) | semmle.label | ...::from(...) |
+| src/main.rs:40:52:40:60 | file_path | semmle.label | file_path |
+| src/main.rs:45:5:45:22 | ...::read_to_string | semmle.label | ...::read_to_string |
+| src/main.rs:45:24:45:32 | file_path | semmle.label | file_path |
+| src/main.rs:50:11:50:19 | file_path | semmle.label | file_path |
+| src/main.rs:53:9:53:17 | file_path | semmle.label | file_path |
+| src/main.rs:53:21:53:62 | public_path.join(...) | semmle.label | public_path.join(...) |
+| src/main.rs:53:38:53:61 | ...::from(...) | semmle.label | ...::from(...) |
+| src/main.rs:53:52:53:60 | file_path | semmle.label | file_path |
+| src/main.rs:54:9:54:17 | file_path | semmle.label | file_path |
+| src/main.rs:54:21:54:44 | file_path.canonicalize(...) [Ok] | semmle.label | file_path.canonicalize(...) [Ok] |
+| src/main.rs:54:21:54:53 | ... .unwrap(...) | semmle.label | ... .unwrap(...) |
+| src/main.rs:59:5:59:22 | ...::read_to_string | semmle.label | ...::read_to_string |
+| src/main.rs:59:24:59:32 | file_path | semmle.label | file_path |
+subpaths
diff --git a/rust/ql/test/query-tests/security/CWE-022/TaintedPath.qlref b/rust/ql/test/query-tests/security/CWE-022/TaintedPath.qlref
new file mode 100644
index 000000000000..2d4a275c02ac
--- /dev/null
+++ b/rust/ql/test/query-tests/security/CWE-022/TaintedPath.qlref
@@ -0,0 +1,4 @@
+query: queries/security/CWE-022/TaintedPath.ql
+postprocess:
+ - utils/test/PrettyPrintModels.ql
+ - utils/test/InlineExpectationsTestQuery.ql
diff --git a/rust/ql/test/query-tests/security/CWE-022/TaintedPathSinks.expected b/rust/ql/test/query-tests/security/CWE-022/TaintedPathSinks.expected
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/rust/ql/test/query-tests/security/CWE-022/TaintedPathSinks.ql b/rust/ql/test/query-tests/security/CWE-022/TaintedPathSinks.ql
new file mode 100644
index 000000000000..66345376de7b
--- /dev/null
+++ b/rust/ql/test/query-tests/security/CWE-022/TaintedPathSinks.ql
@@ -0,0 +1,19 @@
+import rust
+import codeql.rust.security.TaintedPathExtensions
+import utils.test.InlineExpectationsTest
+
+module TaintedPathSinksTest implements TestSig {
+  string getARelevantTag() { result = "path-injection-sink" }
+
+  predicate hasActualResult(Location location, string element, string tag, string value) {
+    exists(TaintedPath::Sink sink |
+      location = sink.getLocation() and
+      location.getFile().getBaseName() != "" and
+      element = sink.toString() and
+      tag = "path-injection-sink" and
+      value = ""
+    )
+  }
+}
+
+import MakeTest<TaintedPathSinksTest>
diff --git a/rust/ql/test/query-tests/security/CWE-022/options.yml b/rust/ql/test/query-tests/security/CWE-022/options.yml
new file mode 100644
index 000000000000..5277d967cc09
--- /dev/null
+++ b/rust/ql/test/query-tests/security/CWE-022/options.yml
@@ -0,0 +1,2 @@
+qltest_dependencies:
+    - poem = { version = "3.1.7" }
diff --git a/rust/ql/test/query-tests/security/CWE-022/rust-toolchain.toml b/rust/ql/test/query-tests/security/CWE-022/rust-toolchain.toml
new file mode 100644
index 000000000000..bd988b083968
--- /dev/null
+++ b/rust/ql/test/query-tests/security/CWE-022/rust-toolchain.toml
@@ -0,0 +1,8 @@
+# This file specifies the Rust version used to develop and test the
+# extractors written in rust. It is set to the lowest version of Rust
+# we want to support.
+
+[toolchain]
+channel = "nightly"
+profile = "minimal"
+components = [ ]
diff --git a/rust/ql/test/query-tests/security/CWE-022/src/main.rs b/rust/ql/test/query-tests/security/CWE-022/src/main.rs
new file mode 100644
index 000000000000..7c13da08db50
--- /dev/null
+++ b/rust/ql/test/query-tests/security/CWE-022/src/main.rs
@@ -0,0 +1,88 @@
+#![feature(file_buffered)]
+use poem::{error::InternalServerError, handler, http::StatusCode, web::Query, Error, Result};
+use std::{fs, path::Path, path::PathBuf};
+//#[handler]
+fn tainted_path_handler_bad(
+    Query(file_name): Query<String>, // $ Source=remote1
+) -> Result<String> {
+    let file_path = PathBuf::from(file_name);
+    // BAD: This could read any file on the filesystem.
+    fs::read_to_string(file_path).map_err(InternalServerError) // $ path-injection-sink Alert[rust/path-injection]=remote1
+}
+
+//#[handler]
+fn tainted_path_handler_good(Query(file_name): Query<String>) -> Result<String> {
+    // GOOD: ensure that the filename has no path separators or parent directory references
+    if file_name.contains("..") || file_name.contains("/") || file_name.contains("\\") {
+        return Err(Error::from_status(StatusCode::BAD_REQUEST));
+    }
+    let file_path = PathBuf::from(file_name);
+    fs::read_to_string(file_path).map_err(InternalServerError) // $ path-injection-sink
+}
+
+//#[handler]
+fn tainted_path_handler_folder_good(Query(file_path): Query<String>) -> Result<String> {
+    let public_path = PathBuf::from("/var/www/public_html");
+    let file_path = public_path.join(PathBuf::from(file_path));
+    let file_path = file_path.canonicalize().unwrap();
+    // GOOD: ensure that the path stays within the public folder
+    if !file_path.starts_with(public_path) {
+        return Err(Error::from_status(StatusCode::BAD_REQUEST));
+    }
+    fs::read_to_string(file_path).map_err(InternalServerError) // $ path-injection-sink
+}
+
+//#[handler]
+fn tainted_path_handler_folder_almost_good1(
+    Query(file_path): Query<String>, // $ Source=remote4
+) -> Result<String> {
+    let public_path = PathBuf::from("/var/www/public_html");
+    let file_path = public_path.join(PathBuf::from(file_path));
+    // BAD: the path could still contain `..` and escape the public folder
+    if !file_path.starts_with(public_path) {
+        return Err(Error::from_status(StatusCode::BAD_REQUEST));
+    }
+    fs::read_to_string(file_path).map_err(InternalServerError) // $ path-injection-sink Alert[rust/path-injection]=remote4
+}
+
+//#[handler]
+fn tainted_path_handler_folder_almost_good2(
+    Query(file_path): Query<String>, // $ Source=remote5
+) -> Result<String> {
+    let public_path = PathBuf::from("/var/www/public_html");
+    let file_path = public_path.join(PathBuf::from(file_path));
+    let file_path = file_path.canonicalize().unwrap();
+    // BAD: the check to ensure that the path stays within the public folder is wrong
+    if file_path.starts_with(public_path) {
+        return Err(Error::from_status(StatusCode::BAD_REQUEST));
+    }
+    fs::read_to_string(file_path).map_err(InternalServerError) // $ path-injection-sink Alert[rust/path-injection]=remote5
+}
+
+fn sinks(path1: &Path, path2: &Path) {
+    let _ = std::fs::copy(path1, path2); // $ path-injection-sink
+    let _ = std::fs::create_dir(path1); // $ path-injection-sink
+    let _ = std::fs::create_dir_all(path1); // $ path-injection-sink
+    let _ = std::fs::hard_link(path1, path2); // $ path-injection-sink
+    let _ = std::fs::metadata(path1); // $ path-injection-sink
+    let _ = std::fs::read(path1); // $ path-injection-sink
+    let _ = std::fs::read_dir(path1); // $ path-injection-sink
+    let _ = std::fs::read_link(path1); // $ path-injection-sink
+    let _ = std::fs::read_to_string(path1); // $ path-injection-sink
+    let _ = std::fs::remove_dir(path1); // $ path-injection-sink
+    let _ = std::fs::remove_dir_all(path1); // $ path-injection-sink
+    let _ = std::fs::remove_file(path1); // $ path-injection-sink
+    let _ = std::fs::rename(path1, path2); // $ path-injection-sink
+    let _ = std::fs::set_permissions(path1, std::os::unix::fs::PermissionsExt::from_mode(7)); // $ path-injection-sink
+    let _ = std::fs::soft_link(path1, path2); // $ path-injection-sink
+    let _ = std::fs::symlink_metadata(path1); // $ path-injection-sink
+    let _ = std::fs::write(path1, "contents"); // $ path-injection-sink
+    let _ = std::fs::DirBuilder::new().create(path1); // $ path-injection-sink
+    let _ = std::fs::File::create(path1); // $ path-injection-sink
+    let _ = std::fs::File::create_buffered(path1); // $ path-injection-sink
+    let _ = std::fs::File::create_new(path1); // $ path-injection-sink
+    let _ = std::fs::File::open(path1); // $ path-injection-sink
+    let _ = std::fs::File::open_buffered(path1); // $ path-injection-sink
+}
+
+fn main() {}


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This pull request adds examples demonstrating both secure and insecure handling of file path tainting in Rust.  
- Introduces a secure example that validates a file name against path traversal in TaintedPathGoodNormalize.rs.  
- Implements a secure example that ensures file access remains within a designated public folder in TaintedPathGoodFolder.rs.  
- Provides an insecure example in TaintedPath.rs to illustrate unsafe file reading.  
- Updates model files to include new taint propagation rules.

### Reviewed Changes

Copilot reviewed 9 out of 23 changed files in this pull request and generated no comments.

<details>
<summary>Show a summary per file</summary>

| File | Description |
| ---- | ----------- |
| rust/ql/src/queries/security/CWE-022/examples/TaintedPathGoodNormalize.rs | Adds a secure file read example with filename validation. |
| rust/ql/src/queries/security/CWE-022/examples/TaintedPathGoodFolder.rs | Adds a secure file read example ensuring file stays within a public folder. |
| rust/ql/src/queries/security/CWE-022/examples/TaintedPath.rs | Adds an insecure file read example to demonstrate tainted file path usage. |
| rust/ql/lib/codeql/rust/frameworks/stdlib/fs.model.yml | Updates the model to include path-injection taint propagation for fs::read_to_string. |
| rust/ql/lib/codeql/rust/frameworks/stdlib/lang-core.model.yml | Adds a taint propagation rule for Result::unwrap. |
</details>


<details>
<summary>Files not reviewed (14)</summary>

* **rust/ql/integration-tests/hello-project/summary.expected**: Language not supported
* **rust/ql/integration-tests/hello-workspace/summary.cargo.expected**: Language not supported
* **rust/ql/integration-tests/hello-workspace/summary.rust-project.expected**: Language not supported
* **rust/ql/lib/codeql/rust/Concepts.qll**: Language not supported
* **rust/ql/lib/codeql/rust/Frameworks.qll**: Language not supported
* **rust/ql/lib/codeql/rust/dataflow/DataFlow.qll**: Language not supported
* **rust/ql/lib/codeql/rust/dataflow/internal/SsaImpl.qll**: Language not supported
* **rust/ql/lib/codeql/rust/frameworks/Poem.qll**: Language not supported
* **rust/ql/lib/codeql/rust/frameworks/stdlib/Stdlib.qll**: Language not supported
* **rust/ql/lib/codeql/rust/security/TaintedPathExtensions.qll**: Language not supported
* **rust/ql/src/queries/security/CWE-022/TaintedPath.qhelp**: Language not supported
* **rust/ql/src/queries/security/CWE-022/TaintedPath.ql**: Language not supported
* **rust/ql/test/query-tests/security/CWE-022/TaintedPath.expected**: Language not supported
* **rust/ql/test/query-tests/security/CWE-022/TaintedPath.qlref**: Language not supported
</details>

<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**rust/ql/src/queries/security/CWE-022/examples/TaintedPathGoodFolder.rs:7**
* The variable 'file_path' shadows the function parameter; use a distinct variable name for clarity.
```
let file_path = public_path.join(PathBuf::from(file_path));
```
</details>

**Tip:** Copilot code review supports C#, Go, Java, JavaScript, Markdown, Python, Ruby and TypeScript, with more languages coming soon.  [Learn more](https://gh.io/copilot-code-reviews-docs)
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T17:54:57Z
user: login: hvitved
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T19:13:52Z
user: login: aibaars
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T10:52:54Z
user: login: geoffw0
body: I've asked some questions, query and qhelp otherwise LGTM.  The examples in the `.qhelp` are clear and helpful. :+1:

Please do a DCA run and request a docs review when you're ready.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T17:19:28Z
user: login: aibaars
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T10:59:55Z
user: login: aibaars
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T11:02:31Z
user: login: aibaars
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T11:05:31Z
user: login: aibaars
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T11:06:39Z
user: login: aibaars
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T11:16:08Z
user: login: aibaars
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T11:19:42Z
user: login: aibaars
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T11:22:30Z
user: login: geoffw0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T11:24:28Z
user: login: geoffw0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T11:25:19Z
user: login: asgerf
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T12:00:14Z
user: login: aibaars
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T16:10:09Z
user: login: asgerf
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T16:33:07Z
user: login: github-advanced-security[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-18T18:51:14Z
user: login: RasmusWL
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-20T11:11:32Z
user: login: aibaars
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T11:16:22Z
user: login: hvitved
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T11:56:31Z
user: login: aibaars
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T12:11:35Z
user: login: asgerf
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T12:14:08Z
user: login: geoffw0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T14:37:15Z
user: login: mchammer01
body: @aibaars - Approving on behalf of Docs ⚡ 
I've left a couple of minor comments due to the repetition of the same verb in the same sentence. Would be good to find a synonym, but this can be merged with the sentences as they are 😅  
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T16:12:54Z
user: login: aibaars
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T09:59:53Z
user: login: mchammer01
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T10:24:17Z
user: login: hvitved
body: A few minor suggestions, otherwise LGTM
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T09:24:19Z
user: login: hvitved
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T12:52:40Z

Review comments:
diff_hunk: @@ -212,7 +212,8 @@ predicate capturedCallWrite(Expr call, BasicBlock bb, int i, Variable v) {
 /** Holds if `v` may be mutably borrowed in `e`. */
 private predicate mutablyBorrows(Expr e, Variable v) {
   e = any(MethodCallExpr mc).getReceiver() and
-  e.(VariableAccess).getVariable() = v
+  e.(VariableAccess).getVariable() = v and
+  v.isMutable()
path: rust/ql/lib/codeql/rust/dataflow/internal/SsaImpl.qll
user: login: hvitved
body: I think it would be nice to factor this bit out into a separate PR, with its own DCA run.
created_at: 2025-03-13T19:13:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -212,7 +212,8 @@ predicate capturedCallWrite(Expr call, BasicBlock bb, int i, Variable v) {
 /** Holds if `v` may be mutably borrowed in `e`. */
 private predicate mutablyBorrows(Expr e, Variable v) {
   e = any(MethodCallExpr mc).getReceiver() and
-  e.(VariableAccess).getVariable() = v
+  e.(VariableAccess).getVariable() = v and
+  v.isMutable()
path: rust/ql/lib/codeql/rust/dataflow/internal/SsaImpl.qll
user: login: aibaars
body: https://github.com/github/codeql/pull/19024
created_at: 2025-03-14T10:52:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,20 @@
+/**
+ * Provides modeling for the `Poem` library.
+ */
+
+private import rust
+private import codeql.rust.Concepts
+private import codeql.rust.dataflow.DataFlow
+
+/**
+ * Parameters of a handler function
+ */
+private class PoemHandlerParam extends RemoteSource::Range {
+  PoemHandlerParam() {
+    exists(TupleStructPat param |
+      param.getResolvedPath() = ["crate::web::query::Query", "crate::web::path::Path"]
+    |
+      this.asPat().getPat() = param.getAField()
path: rust/ql/lib/codeql/rust/frameworks/Poem.qll
user: login: geoffw0
body: I'm trying to understand these sources better.  Poem is a web framework, so the code will be hosting some kind of web site / service here and Path / Query contain data about a request from the client?  And the QL matches to any parameter of a pattern match against the these types, that is, an attempt to extract that data contained in the Path / Query.  We could concerns ourselves with other ways of accessing data within the tuple (for example `query.0` or perhaps code involving `query.into()`), though I suspect what you've written will be the most common pattern.

I guess we're not expressing these as models-as-data sources (with an access path for the field) because that would mean identifying the ultimate source of the Path / Query, which we don't really care about - all Path / Query objects are considered effectively tainted?
created_at: 2025-03-17T14:12:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,62 @@
+use poem::{error::InternalServerError, handler, http::StatusCode, web::Query, Error, Result};
+use std::{env::home_dir, fs, path::PathBuf};
path: rust/ql/test/query-tests/security/CWE-022/src/main.rs
user: login: geoffw0
body: ```suggestion
use std::{fs, path::PathBuf};
```
I believe you removed all uses of `env::home_dir` from the test.
created_at: 2025-03-17T14:25:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -264,3 +266,45 @@ module Cryptography {
 
   class CryptographicAlgorithm = SC::CryptographicAlgorithm;
 }
+
+/** Provides classes for modeling path-related APIs. */
+module Path {
+  /**
+   * A data-flow node that performs path normalization. This is often needed in order
+   * to safely access paths.
+   */
+  class PathNormalization extends DataFlow::Node instanceof PathNormalization::Range {
+    /** Gets an argument to this path normalization that is interpreted as a path. */
+    DataFlow::Node getPathArg() { result = super.getPathArg() }
+  }
+
+  /** Provides a class for modeling new path normalization APIs. */
+  module PathNormalization {
+    /**
+     * A data-flow node that performs path normalization. This is often needed in order
+     * to safely access paths.
+     */
+    abstract class Range extends DataFlow::Node {
+      /** Gets an argument to this path normalization that is interpreted as a path. */
+      abstract DataFlow::Node getPathArg();
+    }
+  }
+
+  /** A data-flow node that checks that a path is safe to access. */
path: rust/ql/lib/codeql/rust/Concepts.qll
user: login: geoffw0
body: I feel this QLDoc could be more helpful.  Based on the implementation
```suggestion
  /** A data-flow node that checks that a path is safe to access in some way, for example by having a controlled prefix. */
```
created_at: 2025-03-17T16:51:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -19,19 +19,72 @@ import codeql.rust.dataflow.DataFlow
 import codeql.rust.dataflow.TaintTracking
 import codeql.rust.security.TaintedPathExtensions
 import TaintedPathFlow::PathGraph
+private import codeql.rust.Concepts
+
+abstract private class NormalizationState extends string {
+  bindingset[this]
+  NormalizationState() { any() }
+}
+
+/** A state signifying that the file path has not been normalized. */
+class NotNormalized extends NormalizationState {
+  NotNormalized() { this = "NotNormalized" }
+}
+
+/** A state signifying that the file path has been normalized, but not checked. */
+class NormalizedUnchecked extends NormalizationState {
+  NormalizedUnchecked() { this = "NormalizedUnchecked" }
+}
 
 /**
- * A taint configuration for tainted data that reaches a file access sink.
+ * This configuration uses two flow states, `NotNormalized` and `NormalizedUnchecked`,
+ * to track the requirement that a file path must be first normalized and then checked
+ * before it is safe to use.
+ *
+ * At sources, paths are assumed not normalized. At normalization points, they change
+ * state to `NormalizedUnchecked` after which they can be made safe by an appropriate
+ * check of the prefix.
+ *
+ * Such checks are ineffective in the `NotNormalized` state.
path: rust/ql/src/queries/security/CWE-022/TaintedPath.ql
user: login: geoffw0
body: Good explanation. :+1:
created_at: 2025-03-17T16:57:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,62 @@
+use poem::{error::InternalServerError, handler, http::StatusCode, web::Query, Error, Result};
+use std::{env::home_dir, fs, path::PathBuf};
+
+//#[handler]
+fn tainted_path_handler_bad(
+    Query(file_name): Query<String>, // $ Source=remote1
+) -> Result<String> {
+    let file_path = PathBuf::from(file_name);
+    // BAD: This could read any file on the filesystem.
+    fs::read_to_string(file_path).map_err(InternalServerError) // $ path-injection-sink Alert[rust/path-injection]=remote1
+}
+
+//#[handler]
+fn tainted_path_handler_good(Query(file_name): Query<String>) -> Result<String> {
+    // GOOD: ensure that the filename has no path separators or parent directory references
+    if file_name.contains("..") || file_name.contains("/") || file_name.contains("\\") {
+        return Err(Error::from_status(StatusCode::BAD_REQUEST));
+    }
+    let file_path = PathBuf::from(file_name);
+    fs::read_to_string(file_path).map_err(InternalServerError) // $ path-injection-sink
+}
+
+//#[handler]
+fn tainted_path_handler_folder_good(Query(file_path): Query<String>) -> Result<String> {
+    let public_path = PathBuf::from("/var/www/public_html");
+    let file_path = public_path.join(PathBuf::from(file_path));
+    let file_path = file_path.canonicalize().unwrap();
+    // GOOD: ensure that the path stays within the public folder
+    if !file_path.starts_with(public_path) {
+        return Err(Error::from_status(StatusCode::BAD_REQUEST));
+    }
+    fs::read_to_string(file_path).map_err(InternalServerError) // $ path-injection-sink
+}
+
+//#[handler]
+fn tainted_path_handler_folder_almost_good1(
+    Query(file_path): Query<String>, // $ Source=remote4
+) -> Result<String> {
+    let public_path = PathBuf::from("/var/www/public_html");
+    let file_path = public_path.join(PathBuf::from(file_path));
+    // BAD: the path could still contain `..` and escape the public folder
+    if !file_path.starts_with(public_path) {
+        return Err(Error::from_status(StatusCode::BAD_REQUEST));
+    }
+    fs::read_to_string(file_path).map_err(InternalServerError) // $ path-injection-sink Alert[rust/path-injection]=remote4
+}
+
+//#[handler]
+fn tainted_path_handler_folder_almost_good2(
+    Query(file_path): Query<String>, // $ Source=remote5
+) -> Result<String> {
+    let public_path = PathBuf::from("/var/www/public_html");
+    let file_path = public_path.join(PathBuf::from(file_path));
+    let file_path = file_path.canonicalize().unwrap();
+    // BAD: thecheck to ensure that the path stays within the public folder is wrong
path: rust/ql/test/query-tests/security/CWE-022/src/main.rs
user: login: geoffw0
body: ```suggestion
    // BAD: the check to ensure that the path stays within the public folder is wrong
```
created_at: 2025-03-17T17:01:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1022,6 +1022,32 @@ final class SingletonContentSet extends ContentSet, TSingletonContentSet {
   override Content getAReadContent() { result = c }
 }
 
+final class OptionalStep extends ContentSet, TOptionalStep {
path: rust/ql/lib/codeql/rust/dataflow/internal/DataFlowImpl.qll
user: login: geoffw0
body: This badly needs a QLDoc.  As it is one might assume it's something to do with Rusts `Option<T>`.

Also, as I understand it these contents are special in that they're never actually put on an access path during data flow, just used to label certain kinds of taint steps.  If that's accurate I think this fact should be called out as well.
created_at: 2025-03-17T17:06:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -53,7 +53,7 @@ module Input implements InputSig<Location, RustDataFlow> {
 
   RustDataFlow::ArgumentPosition callbackSelfParameterPosition() { result.isClosureSelf() }
 
-  ReturnKind getStandardReturnValueKind() { result = TNormalReturnKind() }
+  ReturnKind getStandardReturnValueKind() { result = TNormalReturnKind() and Stage::ref() }
path: rust/ql/lib/codeql/rust/dataflow/internal/FlowSummaryImpl.qll
user: login: geoffw0
body: What does this change do?
created_at: 2025-03-17T17:08:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -264,3 +266,45 @@ module Cryptography {
 
   class CryptographicAlgorithm = SC::CryptographicAlgorithm;
 }
+
+/** Provides classes for modeling path-related APIs. */
+module Path {
+  /**
+   * A data-flow node that performs path normalization. This is often needed in order
+   * to safely access paths.
+   */
+  class PathNormalization extends DataFlow::Node instanceof PathNormalization::Range {
+    /** Gets an argument to this path normalization that is interpreted as a path. */
+    DataFlow::Node getPathArg() { result = super.getPathArg() }
+  }
+
+  /** Provides a class for modeling new path normalization APIs. */
+  module PathNormalization {
+    /**
+     * A data-flow node that performs path normalization. This is often needed in order
+     * to safely access paths.
+     */
+    abstract class Range extends DataFlow::Node {
+      /** Gets an argument to this path normalization that is interpreted as a path. */
+      abstract DataFlow::Node getPathArg();
+    }
+  }
path: rust/ql/lib/codeql/rust/Concepts.qll
user: login: geoffw0
body: `PathNormalization` and `PathNormalization::Range` appear to have no implementations, now that we have the `normalize-path` optional step and MaD model.  Is there a reason to keep these classes?
created_at: 2025-03-17T17:12:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -264,3 +266,45 @@ module Cryptography {
 
   class CryptographicAlgorithm = SC::CryptographicAlgorithm;
 }
+
+/** Provides classes for modeling path-related APIs. */
+module Path {
+  /**
+   * A data-flow node that performs path normalization. This is often needed in order
+   * to safely access paths.
+   */
+  class PathNormalization extends DataFlow::Node instanceof PathNormalization::Range {
+    /** Gets an argument to this path normalization that is interpreted as a path. */
+    DataFlow::Node getPathArg() { result = super.getPathArg() }
+  }
+
+  /** Provides a class for modeling new path normalization APIs. */
+  module PathNormalization {
+    /**
+     * A data-flow node that performs path normalization. This is often needed in order
+     * to safely access paths.
+     */
+    abstract class Range extends DataFlow::Node {
+      /** Gets an argument to this path normalization that is interpreted as a path. */
+      abstract DataFlow::Node getPathArg();
+    }
+  }
+
+  /** A data-flow node that checks that a path is safe to access. */
+  class SafeAccessCheck extends DataFlow::ExprNode {
+    SafeAccessCheck() { this = DataFlow::BarrierGuard<safeAccessCheck/3>::getABarrierNode() }
+  }
+
+  private predicate safeAccessCheck(CfgNodes::AstCfgNode g, Cfg::CfgNode node, boolean branch) {
+    g.(SafeAccessCheck::Range).checks(node, branch)
+  }
+
+  /** Provides a class for modeling new path safety checks. */
+  module SafeAccessCheck {
+    /** A data-flow node that checks that a path is safe to access. */
path: rust/ql/lib/codeql/rust/Concepts.qll
user: login: geoffw0
body: Same comment as for `SafeAccessCheck` itself.
created_at: 2025-03-17T17:13:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,20 @@
+/**
+ * Provides modeling for the `Poem` library.
+ */
+
+private import rust
+private import codeql.rust.Concepts
+private import codeql.rust.dataflow.DataFlow
+
+/**
+ * Parameters of a handler function
+ */
+private class PoemHandlerParam extends RemoteSource::Range {
+  PoemHandlerParam() {
+    exists(TupleStructPat param |
+      param.getResolvedPath() = ["crate::web::query::Query", "crate::web::path::Path"]
+    |
+      this.asPat().getPat() = param.getAField()
path: rust/ql/lib/codeql/rust/frameworks/Poem.qll
user: login: aibaars
body: Yeah all Path/Query objects that are used as parameters in a function declaration should be considered input sources. Ideally we'd also check that the function declaration has a `#[handler]` annotation.  See also https://docs.rs/poem/latest/poem/

The modelling of Poem is just a stop gap to have something that works in the QL tests and looks "nice" in the QHelp. A proper model is best left to a follow-up pull request.
created_at: 2025-03-18T10:59:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -264,3 +266,45 @@ module Cryptography {
 
   class CryptographicAlgorithm = SC::CryptographicAlgorithm;
 }
+
+/** Provides classes for modeling path-related APIs. */
+module Path {
+  /**
+   * A data-flow node that performs path normalization. This is often needed in order
+   * to safely access paths.
+   */
+  class PathNormalization extends DataFlow::Node instanceof PathNormalization::Range {
+    /** Gets an argument to this path normalization that is interpreted as a path. */
+    DataFlow::Node getPathArg() { result = super.getPathArg() }
+  }
+
+  /** Provides a class for modeling new path normalization APIs. */
+  module PathNormalization {
+    /**
+     * A data-flow node that performs path normalization. This is often needed in order
+     * to safely access paths.
+     */
+    abstract class Range extends DataFlow::Node {
+      /** Gets an argument to this path normalization that is interpreted as a path. */
+      abstract DataFlow::Node getPathArg();
+    }
+  }
+
+  /** A data-flow node that checks that a path is safe to access. */
path: rust/ql/lib/codeql/rust/Concepts.qll
user: login: aibaars
body: The qldoc originates from the Python Concepts library. Happy to update the comment in both for consistency,
created_at: 2025-03-18T11:02:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -19,19 +19,72 @@ import codeql.rust.dataflow.DataFlow
 import codeql.rust.dataflow.TaintTracking
 import codeql.rust.security.TaintedPathExtensions
 import TaintedPathFlow::PathGraph
+private import codeql.rust.Concepts
+
+abstract private class NormalizationState extends string {
+  bindingset[this]
+  NormalizationState() { any() }
+}
+
+/** A state signifying that the file path has not been normalized. */
+class NotNormalized extends NormalizationState {
+  NotNormalized() { this = "NotNormalized" }
+}
+
+/** A state signifying that the file path has been normalized, but not checked. */
+class NormalizedUnchecked extends NormalizationState {
+  NormalizedUnchecked() { this = "NormalizedUnchecked" }
+}
 
 /**
- * A taint configuration for tainted data that reaches a file access sink.
+ * This configuration uses two flow states, `NotNormalized` and `NormalizedUnchecked`,
+ * to track the requirement that a file path must be first normalized and then checked
+ * before it is safe to use.
+ *
+ * At sources, paths are assumed not normalized. At normalization points, they change
+ * state to `NormalizedUnchecked` after which they can be made safe by an appropriate
+ * check of the prefix.
+ *
+ * Such checks are ineffective in the `NotNormalized` state.
path: rust/ql/src/queries/security/CWE-022/TaintedPath.ql
user: login: aibaars
body: Don't thank me, this comes from Python, thanks to @yoff and @RasmusWL .
created_at: 2025-03-18T11:05:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -264,3 +266,45 @@ module Cryptography {
 
   class CryptographicAlgorithm = SC::CryptographicAlgorithm;
 }
+
+/** Provides classes for modeling path-related APIs. */
+module Path {
+  /**
+   * A data-flow node that performs path normalization. This is often needed in order
+   * to safely access paths.
+   */
+  class PathNormalization extends DataFlow::Node instanceof PathNormalization::Range {
+    /** Gets an argument to this path normalization that is interpreted as a path. */
+    DataFlow::Node getPathArg() { result = super.getPathArg() }
+  }
+
+  /** Provides a class for modeling new path normalization APIs. */
+  module PathNormalization {
+    /**
+     * A data-flow node that performs path normalization. This is often needed in order
+     * to safely access paths.
+     */
+    abstract class Range extends DataFlow::Node {
+      /** Gets an argument to this path normalization that is interpreted as a path. */
+      abstract DataFlow::Node getPathArg();
+    }
+  }
path: rust/ql/lib/codeql/rust/Concepts.qll
user: login: aibaars
body: They are meant to be extension points provided by the Concepts library. 
created_at: 2025-03-18T11:06:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -53,7 +53,7 @@ module Input implements InputSig<Location, RustDataFlow> {
 
   RustDataFlow::ArgumentPosition callbackSelfParameterPosition() { result.isClosureSelf() }
 
-  ReturnKind getStandardReturnValueKind() { result = TNormalReturnKind() }
+  ReturnKind getStandardReturnValueKind() { result = TNormalReturnKind() and Stage::ref() }
path: rust/ql/lib/codeql/rust/dataflow/internal/FlowSummaryImpl.qll
user: login: aibaars
body: I don't know exactly ;-) It has something to do with caching. I just ported all the relevant looking bits from this PR https://github.com/github/codeql/pull/17412 to to Rust.
created_at: 2025-03-18T11:16:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1022,6 +1022,32 @@ final class SingletonContentSet extends ContentSet, TSingletonContentSet {
   override Content getAReadContent() { result = c }
 }
 
+final class OptionalStep extends ContentSet, TOptionalStep {
path: rust/ql/lib/codeql/rust/dataflow/internal/DataFlowImpl.qll
user: login: aibaars
body: That's absolutely right. This is still a bit of a work-in-progress attempt to integrate @asgerf 's ideas from https://github.com/github/codeql/pull/17412 into our library.
created_at: 2025-03-18T11:19:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -264,3 +266,45 @@ module Cryptography {
 
   class CryptographicAlgorithm = SC::CryptographicAlgorithm;
 }
+
+/** Provides classes for modeling path-related APIs. */
+module Path {
+  /**
+   * A data-flow node that performs path normalization. This is often needed in order
+   * to safely access paths.
+   */
+  class PathNormalization extends DataFlow::Node instanceof PathNormalization::Range {
+    /** Gets an argument to this path normalization that is interpreted as a path. */
+    DataFlow::Node getPathArg() { result = super.getPathArg() }
+  }
+
+  /** Provides a class for modeling new path normalization APIs. */
+  module PathNormalization {
+    /**
+     * A data-flow node that performs path normalization. This is often needed in order
+     * to safely access paths.
+     */
+    abstract class Range extends DataFlow::Node {
+      /** Gets an argument to this path normalization that is interpreted as a path. */
+      abstract DataFlow::Node getPathArg();
+    }
+  }
+
+  /** A data-flow node that checks that a path is safe to access. */
path: rust/ql/lib/codeql/rust/Concepts.qll
user: login: aibaars
body: I agree with the additional clarification. @RasmusWL do you agree, shall I make the same change for Python?
created_at: 2025-03-18T11:22:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -264,3 +266,45 @@ module Cryptography {
 
   class CryptographicAlgorithm = SC::CryptographicAlgorithm;
 }
+
+/** Provides classes for modeling path-related APIs. */
+module Path {
+  /**
+   * A data-flow node that performs path normalization. This is often needed in order
+   * to safely access paths.
+   */
+  class PathNormalization extends DataFlow::Node instanceof PathNormalization::Range {
+    /** Gets an argument to this path normalization that is interpreted as a path. */
+    DataFlow::Node getPathArg() { result = super.getPathArg() }
+  }
+
+  /** Provides a class for modeling new path normalization APIs. */
+  module PathNormalization {
+    /**
+     * A data-flow node that performs path normalization. This is often needed in order
+     * to safely access paths.
+     */
+    abstract class Range extends DataFlow::Node {
+      /** Gets an argument to this path normalization that is interpreted as a path. */
+      abstract DataFlow::Node getPathArg();
+    }
+  }
+
+  /** A data-flow node that checks that a path is safe to access. */
path: rust/ql/lib/codeql/rust/Concepts.qll
user: login: geoffw0
body: Yes I think we should.
created_at: 2025-03-18T11:24:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -264,3 +266,45 @@ module Cryptography {
 
   class CryptographicAlgorithm = SC::CryptographicAlgorithm;
 }
+
+/** Provides classes for modeling path-related APIs. */
+module Path {
+  /**
+   * A data-flow node that performs path normalization. This is often needed in order
+   * to safely access paths.
+   */
+  class PathNormalization extends DataFlow::Node instanceof PathNormalization::Range {
+    /** Gets an argument to this path normalization that is interpreted as a path. */
+    DataFlow::Node getPathArg() { result = super.getPathArg() }
+  }
+
+  /** Provides a class for modeling new path normalization APIs. */
+  module PathNormalization {
+    /**
+     * A data-flow node that performs path normalization. This is often needed in order
+     * to safely access paths.
+     */
+    abstract class Range extends DataFlow::Node {
+      /** Gets an argument to this path normalization that is interpreted as a path. */
+      abstract DataFlow::Node getPathArg();
+    }
+  }
path: rust/ql/lib/codeql/rust/Concepts.qll
user: login: geoffw0
body: OK, I'm happy enough we keep them as an extension point.
created_at: 2025-03-18T11:25:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -16,4 +16,5 @@ extensions:
       - ["lang:std", "<crate::path::PathBuf as crate::convert::From>::from", "Argument[0]", "ReturnValue", "taint", "manual"]
       - ["lang:std", "<crate::path::Path>::join", "Argument[self]", "ReturnValue", "taint", "manual"]
       - ["lang:std", "<crate::path::Path>::join", "Argument[0]", "ReturnValue", "taint", "manual"]
+      - ["lang:std", "<crate::path::Path>::canonicalize", "Argument[self]", "ReturnValue.Field[crate::result::Result::Ok(0)].OptionalStep[normalize-path]", "taint", "manual"]
path: rust/ql/lib/codeql/rust/frameworks/stdlib/fs.model.yml
user: login: asgerf
body: ```suggestion
      - ["lang:std", "<crate::path::Path>::canonicalize", "Argument[self].OptionalStep[normalize-path]", "ReturnValue.Field[crate::result::Result::Ok(0)]", "taint", "manual"]
```

Sorry, I put the optional step in the wrong place when I posted the code. It should be at the end of the input, not at the end of the output.
created_at: 2025-03-18T11:37:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1806,3 +1857,22 @@ private module Cached {
 }
 
 import Cached
+
+cached
+private module OptionalSteps {
+  cached
+  predicate optionalStep(Node node1, string name, Node node2) {
+    FlowSummaryImpl::Private::Steps::summaryReadStep(node1.(Node::FlowSummaryNode).getSummaryNode(),
+      TOptionalStep(name), node2.(Node::FlowSummaryNode).getSummaryNode()) or
+    FlowSummaryImpl::Private::Steps::summaryStoreStep(node1.(Node::FlowSummaryNode).getSummaryNode(),
+      TOptionalStep(name), node2.(Node::FlowSummaryNode).getSummaryNode())
path: rust/ql/lib/codeql/rust/dataflow/internal/DataFlowImpl.qll
user: login: asgerf
body: ```suggestion
    FlowSummaryImpl::Private::Steps::summaryReadStep(node1.(Node::FlowSummaryNode).getSummaryNode(),
      TOptionalStep(name), node2.(Node::FlowSummaryNode).getSummaryNode())
```
With the previous suggestion, the store step should not be necessary.
created_at: 2025-03-18T12:00:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -16,4 +16,5 @@ extensions:
       - ["lang:std", "<crate::path::PathBuf as crate::convert::From>::from", "Argument[0]", "ReturnValue", "taint", "manual"]
       - ["lang:std", "<crate::path::Path>::join", "Argument[self]", "ReturnValue", "taint", "manual"]
       - ["lang:std", "<crate::path::Path>::join", "Argument[0]", "ReturnValue", "taint", "manual"]
+      - ["lang:std", "<crate::path::Path>::canonicalize", "Argument[self]", "ReturnValue.Field[crate::result::Result::Ok(0)].OptionalStep[normalize-path]", "taint", "manual"]
path: rust/ql/lib/codeql/rust/frameworks/stdlib/fs.model.yml
user: login: aibaars
body: When I put the step in the "input" then I cannot get the `isBarrier` predicate to work properly.
created_at: 2025-03-18T16:10:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -16,4 +16,5 @@ extensions:
       - ["lang:std", "<crate::path::PathBuf as crate::convert::From>::from", "Argument[0]", "ReturnValue", "taint", "manual"]
       - ["lang:std", "<crate::path::Path>::join", "Argument[self]", "ReturnValue", "taint", "manual"]
       - ["lang:std", "<crate::path::Path>::join", "Argument[0]", "ReturnValue", "taint", "manual"]
+      - ["lang:std", "<crate::path::Path>::canonicalize", "Argument[self]", "ReturnValue.Field[crate::result::Result::Ok(0)].OptionalStep[normalize-path]", "taint", "manual"]
path: rust/ql/lib/codeql/rust/frameworks/stdlib/fs.model.yml
user: login: asgerf
body: Could you try with an `OptionalBarrier` on the normal summary? Otherwise feel free to use the original form and I'll revisit this when building shared support for optional steps.
created_at: 2025-03-18T16:33:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1105,3 +1130,31 @@
 }
 
 import Cached
+
+cached
+private module OptionalSteps {
+  /**
+   * A step in a flow summary defined using `OptionalStep[name]`. An `OptionalStep` is "opt-in", which means
+   * that by default the step is not present in the flow summary and needs to be explicitly enabled by defining
+   * an additional flow step.
+   */
path: rust/ql/lib/codeql/rust/dataflow/internal/DataFlowImpl.qll
user: login: github-advanced-security[bot]
body: ## Predicate QLDoc style.

The QLDoc for a predicate without a result should start with 'Holds'.

[Show more details](https://github.com/github/codeql/security/code-scanning/94801)
created_at: 2025-03-18T18:51:14Z
author_association: NONE
diff_hunk: @@ -1105,3 +1130,31 @@
 }
 
 import Cached
+
+cached
+private module OptionalSteps {
+  /**
+   * A step in a flow summary defined using `OptionalStep[name]`. An `OptionalStep` is "opt-in", which means
+   * that by default the step is not present in the flow summary and needs to be explicitly enabled by defining
+   * an additional flow step.
+   */
+  cached
+  predicate optionalStep(Node node1, string name, Node node2) {
+    FlowSummaryImpl::Private::Steps::summaryReadStep(node1.(FlowSummaryNode).getSummaryNode(),
+      TOptionalStep(name), node2.(FlowSummaryNode).getSummaryNode()) or
+    FlowSummaryImpl::Private::Steps::summaryStoreStep(node1.(FlowSummaryNode).getSummaryNode(),
+      TOptionalStep(name), node2.(FlowSummaryNode).getSummaryNode())
+  }
+
+  /**
+   * A step in a flow summary defined using `OptionalBarrier[name]`. An `OptionalBarrier` is "opt-out", by default
+   * data can flow freely through the step. Flow through the step can be explicity blocked by defining its node as a barrier.
+   */
path: rust/ql/lib/codeql/rust/dataflow/internal/DataFlowImpl.qll
user: login: github-advanced-security[bot]
body: ## Predicate QLDoc style.

The QLDoc for a predicate without a result should start with 'Holds'.

[Show more details](https://github.com/github/codeql/security/code-scanning/94802)
created_at: 2025-03-18T18:51:14Z
author_association: NONE
diff_hunk: @@ -53,7 +53,7 @@ module Input implements InputSig<Location, RustDataFlow> {
 
   RustDataFlow::ArgumentPosition callbackSelfParameterPosition() { result.isClosureSelf() }
 
-  ReturnKind getStandardReturnValueKind() { result = TNormalReturnKind() }
+  ReturnKind getStandardReturnValueKind() { result = TNormalReturnKind() and Stage::ref() }
path: rust/ql/lib/codeql/rust/dataflow/internal/FlowSummaryImpl.qll
user: login: RasmusWL
body: Ah, this is the old trick to force fewer stages. We have some [internal docs](https://github.com/github/codeql-team/blob/main/docs/best-practices/cached-stages-pattern.md) on it, but overall it's no longer needed after the CodeQL evaluator has been changed in regards to stages.
created_at: 2025-03-20T11:11:32Z
author_association: MEMBER
diff_hunk: @@ -53,7 +53,7 @@ module Input implements InputSig<Location, RustDataFlow> {
 
   RustDataFlow::ArgumentPosition callbackSelfParameterPosition() { result.isClosureSelf() }
 
-  ReturnKind getStandardReturnValueKind() { result = TNormalReturnKind() }
+  ReturnKind getStandardReturnValueKind() { result = TNormalReturnKind() and Stage::ref() }
path: rust/ql/lib/codeql/rust/dataflow/internal/FlowSummaryImpl.qll
user: login: aibaars
body: Thanks, let's just get rid of it for now, unless @asgerf disagrees.
created_at: 2025-03-20T11:16:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1105,3 +1130,29 @@ private module Cached {
 }
 
 import Cached
+
+cached
+private module OptionalSteps {
path: rust/ql/lib/codeql/rust/dataflow/internal/DataFlowImpl.qll
user: login: hvitved
body: This should be inside the existing `Cached` module to make sure that they are evaluated together.
created_at: 2025-03-20T11:56:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -16,4 +16,5 @@ extensions:
       - ["lang:std", "<crate::path::PathBuf as crate::convert::From>::from", "Argument[0]", "ReturnValue", "taint", "manual"]
       - ["lang:std", "<crate::path::Path>::join", "Argument[self]", "ReturnValue", "taint", "manual"]
       - ["lang:std", "<crate::path::Path>::join", "Argument[0]", "ReturnValue", "taint", "manual"]
+      - ["lang:std", "<crate::path::Path>::canonicalize", "Argument[self]", "ReturnValue.Field[crate::result::Result::Ok(0)].OptionalStep[normalize-path]", "taint", "manual"]
path: rust/ql/lib/codeql/rust/frameworks/stdlib/fs.model.yml
user: login: aibaars
body: Yes that works, see also: https://github.com/github/codeql/pull/18960/commits/2804c130279cb74e4c5eee7851535ed998f15e00
created_at: 2025-03-20T12:11:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -53,7 +53,7 @@ module Input implements InputSig<Location, RustDataFlow> {
 
   RustDataFlow::ArgumentPosition callbackSelfParameterPosition() { result.isClosureSelf() }
 
-  ReturnKind getStandardReturnValueKind() { result = TNormalReturnKind() }
+  ReturnKind getStandardReturnValueKind() { result = TNormalReturnKind() and Stage::ref() }
path: rust/ql/lib/codeql/rust/dataflow/internal/FlowSummaryImpl.qll
user: login: asgerf
body: No objections from me (assuming performance looks fine).
created_at: 2025-03-20T12:14:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1022,6 +1022,32 @@ final class SingletonContentSet extends ContentSet, TSingletonContentSet {
   override Content getAReadContent() { result = c }
 }
 
+final class OptionalStep extends ContentSet, TOptionalStep {
path: rust/ql/lib/codeql/rust/dataflow/internal/DataFlowImpl.qll
user: login: geoffw0
body: Looks like this has been addressed, thanks.
created_at: 2025-03-21T14:37:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,67 @@
+<!DOCTYPE qhelp PUBLIC
+  "-//Semmle//qhelp//EN"
+  "qhelp.dtd">
+<qhelp>
+<overview>
+<p>Accessing paths controlled by users can allow an attacker to access unexpected resources. This
path: rust/ql/src/queries/security/CWE-022/TaintedPath.qhelp
user: login: mchammer01
body: This LGTM, just highlighting that we're using the verb "to access" twice in this sentence. Perhaps we could use a synonym to avoid repetition?
Not 100% happy with my suggestion though 🤔 

```suggestion
<p>Accessing paths controlled by users can allow an attacker to reach unexpected resources. This
```
created_at: 2025-03-24T16:05:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,99 @@
+/**
+ * @name Uncontrolled data used in path expression
+ * @description Accessing paths influenced by users can allow an attacker to access unexpected resources.
path: rust/ql/src/queries/security/CWE-022/TaintedPath.ql
user: login: mchammer01
body: Same comment as above.

created_at: 2025-03-24T16:09:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,67 @@
+<!DOCTYPE qhelp PUBLIC
+  "-//Semmle//qhelp//EN"
+  "qhelp.dtd">
+<qhelp>
+<overview>
+<p>Accessing paths controlled by users can allow an attacker to access unexpected resources. This
path: rust/ql/src/queries/security/CWE-022/TaintedPath.qhelp
user: login: aibaars
body: I agree with the comment, but I'm not 100% sold on the suggestion either. I just checked and the 7 other languages use the same phrase with the double use of "access". So we should either change all 8 QL and QHelp files consistently or none. 

If you feel strongly about the need to avoid the double use of access then I'd be happy to address this in a separate pull request for all languages. 

I don't really like "reach", it is even more vague than "access" . A clearer phrase would be `... to read, write or modify unexpected resources`, but that is perhaps a bit too long. 
created_at: 2025-03-25T09:59:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,67 @@
+<!DOCTYPE qhelp PUBLIC
+  "-//Semmle//qhelp//EN"
+  "qhelp.dtd">
+<qhelp>
+<overview>
+<p>Accessing paths controlled by users can allow an attacker to access unexpected resources. This
path: rust/ql/src/queries/security/CWE-022/TaintedPath.qhelp
user: login: mchammer01
body: Let's leave the verbiage as-is, if it's also used in other queries 👍🏻 😅 
created_at: 2025-03-25T10:24:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -264,3 +266,45 @@ module Cryptography {
 
   class CryptographicAlgorithm = SC::CryptographicAlgorithm;
 }
+
+/** Provides classes for modeling path-related APIs. */
+module Path {
+  /**
+   * A data-flow node that performs path normalization. This is often needed in order
+   * to safely access paths.
+   */
+  class PathNormalization extends DataFlow::Node instanceof PathNormalization::Range {
+    /** Gets an argument to this path normalization that is interpreted as a path. */
+    DataFlow::Node getPathArg() { result = super.getPathArg() }
+  }
path: rust/ql/lib/codeql/rust/Concepts.qll
user: login: hvitved
body: ```suggestion
  final class PathNormalization = PathNormalization::Range;
```
created_at: 2025-03-26T09:13:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1093,7 +1101,24 @@ private module Cached {
   newtype TReturnKind = TNormalReturnKind()
 
   cached
-  newtype TContentSet = TSingletonContentSet(Content c)
+  newtype TContentSet =
+    TSingletonContentSet(Content c) or
+    TOptionalStep(string name) {
+      name = any(FlowSummaryImpl::Private::AccessPathToken tok).getAnArgument("OptionalStep")
+    } or
+    TOptionalBarrier(string name) {
+      name = any(FlowSummaryImpl::Private::AccessPathToken tok).getAnArgument("OptionalBarrier")
+    }
+
+  /**
+   * Holds if `cs` is used to encode a special operation as a content component, but should not
+   * be treated as an ordinary content component.
+   */
+  cached
+  predicate isSpecialContentSet(ContentSet cs) {
path: rust/ql/lib/codeql/rust/dataflow/internal/DataFlowImpl.qll
user: login: hvitved
body: I think this can be private and non-cached?
created_at: 2025-03-26T09:17:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,99 @@
+/**
+ * @name Uncontrolled data used in path expression
+ * @description Accessing paths influenced by users can allow an attacker to access unexpected resources.
+ * @kind path-problem
+ * @problem.severity error
+ * @security-severity 7.5
+ * @precision high
+ * @id rust/path-injection
+ * @tags security
+ *       external/cwe/cwe-022
+ *       external/cwe/cwe-023
+ *       external/cwe/cwe-036
+ *       external/cwe/cwe-073
+ *       external/cwe/cwe-099
+ */
+
+import rust
+import codeql.rust.dataflow.DataFlow
+import codeql.rust.dataflow.internal.DataFlowImpl as DataflowImpl
+import codeql.rust.dataflow.TaintTracking
+import codeql.rust.security.TaintedPathExtensions
+import TaintedPathFlow::PathGraph
+private import codeql.rust.Concepts
+
+abstract private class NormalizationState extends string {
path: rust/ql/src/queries/security/CWE-022/TaintedPath.ql
user: login: hvitved
body: Nit: I think it would be cleaner to use a `newtype` to represent the state.
created_at: 2025-03-26T09:22:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,3 @@
+qltest_cargo_check: true
path: rust/ql/test/query-tests/security/CWE-022/options.yml
user: login: hvitved
body: I don't _think_ this line is needed, it should be inherited from `rust/ql/test/options.yml`?
created_at: 2025-03-26T09:23:47Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-10T12:37:57Z
body: QHelp previews:
<details> <summary>rust/ql/src/queries/security/CWE-022/TaintedPath.qhelp</summary>

# Uncontrolled data used in path expression
Accessing paths controlled by users can allow an attacker to access unexpected resources. This can result in sensitive information being revealed or deleted, or an attacker being able to influence behavior by modifying unexpected files.

Paths that are naively constructed from data controlled by a user may be absolute paths, or may contain unexpected special characters such as "..". Such a path could point anywhere on the file system.


## Recommendation
Validate user input before using it to construct a file path.

Common validation methods include checking that the normalized path is relative and does not contain any ".." components, or checking that the path is contained within a safe folder. The method you should use depends on how the path is used in the application, and whether the path should be a single path component.

If the path should be a single path component (such as a file name), you can check for the existence of any path separators ("/" or "\\"), or ".." sequences in the input, and reject the input if any are found.

Note that removing "../" sequences is *not* sufficient, since the input could still contain a path separator followed by "..". For example, the input ".../...//" would still result in the string "../" if only "../" sequences are removed.

Finally, the simplest (but most restrictive) option is to use an allow list of safe patterns and make sure that the user input matches one of these patterns.


## Example
In this example, a user-provided file name is read from a HTTP request and then used to access a file and send it back to the user. However, a malicious user could enter a file name anywhere on the file system, such as "/etc/passwd" or "../../../etc/passwd".


```rust
use poem::{error::InternalServerError, handler, web::Query, Result};
use std::{fs, path::PathBuf};

#[handler]
fn tainted_path_handler(Query(file_name): Query<String>) -> Result<String> {
    let file_path = PathBuf::from(file_name);
    // BAD: This could read any file on the filesystem.
    fs::read_to_string(file_path).map_err(InternalServerError)
}

```
If the input should only be a file name, you can check that it doesn't contain any path separators or ".." sequences.


```rust
use poem::{error::InternalServerError, handler, http::StatusCode, web::Query, Error, Result};
use std::{fs, path::PathBuf};

#[handler]
fn tainted_path_handler(Query(file_name): Query<String>) -> Result<String> {
    // GOOD: ensure that the filename has no path separators or parent directory references
    if file_name.contains("..") || file_name.contains("/") || file_name.contains("\\") {
        return Err(Error::from_status(StatusCode::BAD_REQUEST));
    }
    let file_path = PathBuf::from(file_name);
    fs::read_to_string(file_path).map_err(InternalServerError)
}

```
If the input should be within a specific directory, you can check that the resolved path is still contained within that directory.


```rust
use poem::{error::InternalServerError, handler, http::StatusCode, web::Query, Error, Result};
use std::{env::home_dir, fs, path::PathBuf};

#[handler]
fn tainted_path_handler(Query(file_path): Query<String>) -> Result<String, Error> {
    let public_path = home_dir().unwrap().join("public");
    let file_path = public_path.join(PathBuf::from(file_path));
    let file_path = file_path.canonicalize().unwrap();
    // GOOD: ensure that the path stays within the public folder
    if !file_path.starts_with(public_path) {
        return Err(Error::from_status(StatusCode::BAD_REQUEST));
    }
    fs::read_to_string(file_path).map_err(InternalServerError)
}

```

## References
* OWASP: [Path Traversal](https://owasp.org/www-community/attacks/Path_Traversal).
* Common Weakness Enumeration: [CWE-22](https://cwe.mitre.org/data/definitions/22.html).
* Common Weakness Enumeration: [CWE-23](https://cwe.mitre.org/data/definitions/23.html).
* Common Weakness Enumeration: [CWE-36](https://cwe.mitre.org/data/definitions/36.html).
* Common Weakness Enumeration: [CWE-73](https://cwe.mitre.org/data/definitions/73.html).
* Common Weakness Enumeration: [CWE-99](https://cwe.mitre.org/data/definitions/99.html).
</details>

author_association: CONTRIBUTOR
user: login: aibaars
created_at: 2025-03-18T11:12:23Z
body: @geoffw0 The QHelp is exactly the same as C# and Java, the only difference are the examples which are meant to resemble the C#/Java variants as closely as possible. See for example: https://codeql.github.com/codeql-query-help/csharp/cs-path-injection/ . 
author_association: CONTRIBUTOR
user: login: yoff
created_at: 2025-03-19T10:40:14Z
body: Python 👍 
author_association: CONTRIBUTOR
user: login: geoffw0
created_at: 2025-03-21T14:44:46Z
body: All of my concerns have been addressed, but I don't speak for other people's comments.  I've taken the liberty of adding the `ready-for-doc-review` tag to get that aspect moving.  Let me know if there's anything I can do to help get this merged.
author_association: CONTRIBUTOR