Documentation
¶
Overview ¶
Package pathinside judges path NAMES, along two axes that must not be fused.
CONTAINMENT needs a root and asks where a path points: is this cleaned target the same as this root, or beneath it? Inside and RelEscapes answer it.
SYNTACTIC HYGIENE needs no root and asks how a path is written: does it spell a traversal, is it in cleaned form? HasDotDot and IsCanonical answer that.
The axes are separate because they disagree, and they disagree on the inputs that matter. Containment cleans first, so a traversal that normalizes away is not an escape — "/run/secrets/../../etc/shadow" cleans to "/etc/shadow", which leaves no root and passes. Hygiene never cleans, so the same path fails, on the grounds that a legitimate credential path was not written with two traversals in it. Answering a hygiene question with a containment function is therefore not a near-miss but an inversion: the refusal becomes an acceptance, at whatever boundary the caller was guarding.
Containment ¶
Every program that hands an externally-influenced path to the filesystem needs that answer somewhere — an archive entry name before extraction, a filesystem-event path before it extends a watch set, a request-supplied file path before it is read or deleted, a path read back out of a log the program wrote earlier. The correct lexical rule is short, and the shapes that are nearly it are wrong in ways that do not show up in a passing test:
- strings.HasPrefix(target, root) accepts a SIBLING whose name merely starts with the root's: with root "/srv/data", the path "/srv/data-evil" passes the prefix test and is not inside anything. Appending a separator to the root before the prefix test fixes that one case and introduces another (it now rejects the root itself, and it answers on unclean input the way its author's examples never did).
- filepath.Rel plus a leading-".." STRING test refuses the legitimate name "..extras/movie.mkv", whose first segment happens to begin with two dots.
The rule that is right on both counts is filepath.Rel followed by a SEPARATOR-PRECISE test of the result: the relative path escapes exactly when it is ".." or begins with ".." followed by a separator. Rel is what defeats the prefix sibling — Rel("/srv/data", "/srv/data-evil") is "../data-evil", so the target is reached by leaving the root, which is what "outside" means — and the separator is what keeps "..extras" a name rather than a traversal.
Inside is that rule. RelEscapes is its second half on its own, for a caller that must validate a relative NAME before joining it onto anything, or that already holds a filepath.Rel result it needs for other work (an os.Root-relative Stat or Remove) and should not pay for a second Rel. Name validation stays a separate question from containment on purpose: the two are asked at different moments, and they do not always agree. RelEscapes is the stricter of the two — a name that leaves the root and returns to a directory with the same name ("../a" under root "a") is refused as a name while its joined result is Inside — and a caller validating an untrusted name wants that strictness, because a legitimate name has no business leaving. Fusing them would pick one answer for both callers.
Syntactic hygiene ¶
The commoner question in practice has no root at all. A credential path, a backup destination, a cache directory read from a config file or a flag is judged on its own: it was meant to be written plainly, and a traversal in it is a red flag whatever it resolves to. HasDotDot is that test — does p contain a ".." COMPONENT, as written, without cleaning — and IsCanonical is its companion, whether p is already in filepath.Clean form. The composed rule most such callers want is the OR of the two: !IsCanonical(p) || HasDotDot(p) refuses a path that is either unclean or traversing.
Both halves are needed, because neither implies the other. ".." and "../dumps" are perfectly canonical, so a canonicality test alone accepts a leading traversal; and "/dumps/../etc" is traversing while "/dumps/a..b" and "key..v2" are ordinary names, so the traversal test must be component-precise rather than a substring search. Canonicality is what BOUNDS the disagreement between the axes: filepath.Clean leaves ".." components only at the front of a relative path, so on canonical input HasDotDot and RelEscapes always agree, and they diverge only on unclean input — the input an attacker supplies.
The separator handling in HasDotDot is the part worth centralizing. It splits filepath.ToSlash(p) on "/", so a backslash counts as a separator only on Windows, where it is one: on Unix `a\..\b` is a single legal filename and must not read as traversal, on Windows it is three components and must. A hand-rolled split on both characters is wrong on Unix, a split on filepath.Separator alone is wrong on Windows, and strings.Contains(p, "..") is wrong everywhere.
Lexical, not enforced ¶
All four functions are LEXICAL. They compare and inspect names and resolve nothing: a symlink inside the root pointing anywhere at all is still lexically inside it, and a path that passes can still be swapped between the check and the syscall. That is the right answer for a name-level decision (is this path mine to handle) and the wrong one for an access-level decision (may this open succeed). Callers that open, read, write, rename or remove through the path want kernel-enforced confinement — os.Root's os.OpenRoot / os.OpenInRoot, which refuse to traverse a symlink out of the tree — with this package's cheap lexical gate in front of it where the caller also wants an early, quiet refusal.
The root itself is inside: Inside(root, root) is true. A caller that must exclude it (an operation whose empty relative name would rewrite the tree's own directory) tests that separately; a false from Inside never means "equal to root".
Standard library only, zero dependencies.
Index ¶
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func HasDotDot ¶
HasDotDot reports whether p contains a ".." path COMPONENT, examined AS WRITTEN. It is this package's second axis — syntactic hygiene — and it takes no root, because it asks nothing about where p points: only whether the path a caller was handed spells a traversal at all.
p is NOT cleaned, and that is the whole of the difference from RelEscapes. The two give OPPOSITE answers on exactly the inputs that matter: "/run/secrets/../../etc/shadow" cleans to "/etc/shadow", which leaves no root, so RelEscapes reports false — while this function reports true, because the traversal is written. Reach for RelEscapes when the question is "after normalization, does this name leave its root": an archive entry about to be joined, a configured sub-path resolved against a base. Reach for this function when the question is "was I handed a path with traversal in it": a config-supplied credential path, a backup destination, a value a human was supposed to spell plainly. Such a path is not suspicious because of where it resolves — it is suspicious because a legitimate one would not have been written that way — and answering that question with RelEscapes converts a deliberate refusal into an acceptance.
The components are taken from filepath.ToSlash(p) split on "/", and that choice is the reason this rule is worth centralizing instead of hand-rolling per call site. ToSlash rewrites "\" to "/" ONLY on Windows, which is exactly the platform rule: on Unix a backslash is a legal filename byte, so `a\..\b` is ONE component whose name happens to contain dots and must NOT read as traversal; on Windows it IS a separator, so the same string is three components and must. Every shorter spelling is wrong somewhere — a split on both characters refuses a legal Unix filename, a split on filepath.Separator alone misses the Windows traversal, and strings.Contains(p, "..") is wrong on every platform at once, refusing ordinary names like "key..v2" and "/dumps/a..b" whose only sin is two adjacent dots.
Only an exact ".." component counts. "..." is a directory name, so are "..extras" and "a..b": containing or beginning with two dots is not traversing. The empty string has no components and is false. ".." alone is true, and a ".." anywhere in the path is true — first component, last, or buried in the middle, which is the case cleaning would have hidden.
HasDotDot judges nothing else. It says nothing about absoluteness (the caller's refusal, as with RelEscapes), nothing about whether p is otherwise cleanly written (pair it with IsCanonical when unclean spellings must be refused too), and nothing about what p resolves to — a symlink component is invisible to it, the same lexical limit the rest of this package carries.
Example ¶
ExampleHasDotDot judges config-supplied values on their own, with no root to compare them against: a path a human was supposed to spell plainly is refused when it spells a traversal, whatever it resolves to. The last two are ordinary names — two adjacent dots inside a component are not a traversal, which is what a strings.Contains test gets wrong.
package main
import (
"fmt"
"github.com/cplieger/pathinside"
)
func main() {
for _, p := range []string{"/run/secrets/pgpass", "/run/secrets/../../etc/shadow", "/dumps/a..b", "key..v2"} {
fmt.Printf("%-32s %v\n", p, pathinside.HasDotDot(p))
}
}
Output: /run/secrets/pgpass false /run/secrets/../../etc/shadow true /dumps/a..b false key..v2 false
func Inside ¶
Inside reports whether target names root itself or a path beneath it. It is the containment predicate a caller reaches for before letting an externally-influenced path reach the filesystem: an archive entry name, a filesystem-event path, a request-supplied file path, a path read back out of a log the program itself wrote.
Both arguments are cleaned: filepath.Rel cleans base and target itself, so a caller passing filepath.Clean'd paths and one passing raw ones get the same answer, and "/a/b/" , "/a/./b" and "/a/x/../b" are all the same root. Nothing else is normalized — no symlink resolution, no case folding, no Unicode normalization, no conversion between absolute and relative.
ROOT ITSELF IS INSIDE. Inside(root, root) is true, because the question this predicate answers is "may this path be treated as part of the tree rooted at root", and the tree includes its own root: a scan that starts at root, a watch registered on root, an archive's "./" entry. A caller that must EXCLUDE the root (an operation that would rewrite or delete the tree's own directory when handed an empty relative name) needs a second, explicit test of its own — do not read a false from this function as "not equal to root".
The comparison is LEXICAL, and that is the whole contract: it says nothing about what the two paths resolve to. A symlink at root/link pointing at /etc makes root/link/passwd lexically inside root, and this function reports it as such. Lexical containment is the right answer for a NAME-level decision (is this name mine to handle) and the wrong one for an ACCESS-level decision (may this open succeed). A caller that opens, reads, writes, renames or removes through the path needs kernel-enforced confinement — os.Root (os.OpenRoot, os.OpenInRoot), which refuses to traverse a symlink out of the tree and closes the TOCTOU window this predicate cannot see. Use both when both questions apply: the cheap lexical gate first, the confined handle for the operation.
Two paths that cannot be compared lexically are refused rather than guessed: an absolute target against a relative root (or the reverse), and on Windows two different volumes. filepath.Rel reports those as an error, and this function answers false, so an unanswerable comparison never reads as containment.
Example ¶
ExampleInside shows the three answers that matter: the root itself is inside, a real descendant is inside, and a sibling whose name merely extends the root's is not — the case a strings.HasPrefix test accepts.
package main
import (
"fmt"
"github.com/cplieger/pathinside"
)
func main() {
root := "/srv/data"
fmt.Println(pathinside.Inside(root, "/srv/data"))
fmt.Println(pathinside.Inside(root, "/srv/data/2026/report.csv"))
fmt.Println(pathinside.Inside(root, "/srv/data-evil/report.csv"))
fmt.Println(pathinside.Inside(root, "/srv/data/../../etc/passwd"))
}
Output: true true false false
func IsCanonical ¶
IsCanonical reports whether p is already in filepath.Clean form — cleaning it would change nothing. It is the other half of the hygiene axis, and it needs no root either.
It exists because "does this path resolve somewhere acceptable" and "is this path written plainly" are different questions, and a caller validating a human-written value — a config field, a CLI flag — usually wants the second. Refusing a non-canonical path refuses in one test the whole class of spellings that a later normalization would silently rewrite: a trailing separator, a doubled separator, a "." component, a traversal buried mid-path. The caller gets a refusal it can explain ("write the path plainly") instead of accepting one string and operating on another.
Canonicality is NOT hygiene, and that is why this is a separate predicate rather than a stricter HasDotDot: ".." and "../dumps" are perfectly canonical, so a canonicality test alone accepts a leading traversal. The composed rule a validating caller wants is the OR of both, !IsCanonical(p) || HasDotDot(p), which refuses a path that is either unclean or traversing.
Canonicality is also what bounds the disagreement between the two axes. Clean leaves ".." components only at the FRONT of a relative path, so a canonical path containing one escapes by RelEscapes too: on canonical input the axes always agree, and they diverge only on unclean input — which is precisely the input an attacker supplies and a config file rarely holds by accident.
The test is a string identity against filepath.Clean, so it inherits Clean's platform rules. The empty string is not canonical (it cleans to "."), and on Windows a slash-written path is not canonical either, because Clean rewrites separators there ("a/b" cleans to `a\b`). A caller that means to accept slash-written input on Windows converts with filepath.FromSlash before testing, not after.
Example ¶
ExampleIsCanonical shows the composed rule a validating caller wants, !IsCanonical(p) || HasDotDot(p), and why it takes both halves: an unclean spelling is refused by canonicality alone, while a leading traversal is perfectly canonical and is refused only by HasDotDot.
package main
import (
"fmt"
"github.com/cplieger/pathinside"
)
func main() {
for _, p := range []string{"/dumps", "/dumps/", "/dumps/./nightly", "..", "../dumps"} {
accepted := pathinside.IsCanonical(p) && !pathinside.HasDotDot(p)
fmt.Printf("%-18s canonical=%-5v traversing=%-5v accepted=%v\n", p, pathinside.IsCanonical(p), pathinside.HasDotDot(p), accepted)
}
}
Output: /dumps canonical=true traversing=false accepted=true /dumps/ canonical=false traversing=false accepted=false /dumps/./nightly canonical=false traversing=false accepted=false .. canonical=true traversing=true accepted=false ../dumps canonical=true traversing=true accepted=false
func RelEscapes ¶
RelEscapes reports whether rel, read as a path relative to some root, leaves that root: it IS ".." or it begins with ".." followed by a separator.
It is deliberately a separate function from Inside, not folded into it: the two answer different questions, and fusing them would force every caller to buy both. Inside asks whether one path lies within another. RelEscapes asks whether a relative NAME is well-formed for use under a root — which a caller must ask BEFORE it joins the name onto anything (an archive entry name, a configured sub-path), and which a caller holding a filepath.Rel result it needs for other work (an os.Root-relative Stat or Remove) can ask without paying for a second Rel.
rel is cleaned first, so an uncleaned name whose traversal is buried mid-string ("a/../../etc") is still caught. A relative name whose result is the root itself (".", or the empty string, which cleans to ".") does not escape.
Cleaning is also what this function cannot see past, and that is the boundary between this package's two axes. A traversal that NORMALIZES AWAY is not an escape: "/run/secrets/../../etc/shadow" cleans to "/etc/shadow", leaves no root, and is reported false here. A caller whose question is instead whether a path was WRITTEN with traversal in it at all — a config-supplied credential path, a backup destination, any value a human was supposed to spell plainly — is asking HasDotDot, and reaching for RelEscapes there turns a deliberate refusal into an acceptance.
This is a NAME rule, and it is deliberately stricter than Inside's locational one: a name that walks out of the root and back into a directory that happens to share the root's name ("../a" under root "a") is refused here while the joined result — the root itself — is Inside. A caller validating an untrusted name wants the strict answer, because a legitimate name has no business leaving; a caller classifying a path it was handed wants Inside. Fusing the two would silently pick one of those answers for both callers.
The test is separator-precise, and that precision is the reason this rule is worth centralizing. A leading-".." STRING prefix test would refuse the perfectly legitimate name "..extras/movie.mkv", whose first segment merely begins with two dots. Requiring the separator (or an exact "..") splits the two cases apart: "../x" escapes, "..extras/x" does not.
RelEscapes says nothing about whether rel is relative at all. An absolute path does not "escape" by this test — "/etc/passwd" cleans to itself, is not "..", and does not begin with "../" — so a caller validating an untrusted name must reject absolute paths separately (filepath.IsAbs, plus a leading separator check where a foreign path syntax is in play). That refusal is not cosmetic: filepath.Clean CLAMPS a traversal at the filesystem root, so "/.." cleans to "/" and is accepted here, while filepath.Join re-attaches the unclamped traversal to a relative base — filepath.Join("data", "/..") is ".", which is above "data". An absolute name is the caller's to refuse, on the grounds that a legitimate relative name was not absolute in the first place.
Example ¶
ExampleRelEscapes validates an archive entry name before it is joined onto an extraction directory. A traversal is refused; a name that merely begins with two dots is a name, not a traversal. RelEscapes says nothing about absoluteness, so the caller rejects an absolute entry itself.
package main
import (
"fmt"
"path/filepath"
"github.com/cplieger/pathinside"
)
func main() {
for _, name := range []string{"docs/readme.md", "../../etc/passwd", "..extras/movie.mkv", "/etc/passwd"} {
switch {
case filepath.IsAbs(name):
fmt.Printf("%-20s refused: absolute\n", name)
case pathinside.RelEscapes(name):
fmt.Printf("%-20s refused: escapes the extraction directory\n", name)
default:
fmt.Printf("%-20s accepted\n", name)
}
}
}
Output: docs/readme.md accepted ../../etc/passwd refused: escapes the extraction directory ..extras/movie.mkv accepted /etc/passwd refused: absolute
Types ¶
This section is empty.