Security considerations, threat model, and input validation.
plait is a local CLI tool that processes user-provided files. It runs with the user's permissions and has access to the local filesystem.
┌─────────────────────────────────────────────────────────────┐
│ Trusted Zone │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ User │───►│ plait │───►│ Output │ │
│ │ (invokes) │ │ (CLI) │ │ (stdout/file) │ │
│ └─────────────┘ └──────┬──────┘ └─────────────────┘ │
│ │ │
│ ┌─────────────────────────▼─────────────────────────────┐ │
│ │ Local Filesystem │ │
│ │ • ~/.config/plait/modules/ (user modules) │ │
│ │ • ~/.config/plait/presets/ (user presets) │ │
│ │ • ~/.config/plait/config.toml (user config) │ │
│ │ • ./plait.toml (project config) │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Key properties:
- All operations are local (no network access)
- Runs with invoking user's permissions
- No privilege escalation
- No persistent state between invocations
| Property | Status | Notes |
|---|---|---|
| No network access | Yes | Pure filesystem operations |
| No code execution | Partial | MiniJinja templates are sandboxed, no arbitrary code |
| No privilege escalation | Yes | Runs as invoking user |
| Deterministic output | Yes | Same inputs produce same outputs |
Risk: Malicious module IDs or include patterns could escape configured directories.
Mitigations:
- Module IDs are validated (no
..components) - Paths are canonicalized before access
- Symlink cycles are detected and rejected
fn validate_module_id(id: &str) -> Result<(), ValidationError> {
// Reject path traversal
if id.contains("..") {
return Err(ValidationError::PathTraversal { id: id.into() });
}
// Reject absolute paths
if id.starts_with('/') || id.contains(':') {
return Err(ValidationError::AbsolutePath { id: id.into() });
}
// Reject control characters
if id.chars().any(|c| c.is_control()) {
return Err(ValidationError::InvalidCharacters { id: id.into() });
}
Ok(())
}Risk: User-provided context variables could inject malicious template code.
Mitigations:
- MiniJinja is sandboxed (no filesystem access, no code execution)
- Context values are data, not templates
- No dynamic template loading from untrusted sources
// Context values are interpolated as data, not compiled as templates
let context = Context::from_iter([
("user_input", user_provided_value), // Safe: treated as string
]);
// This is NOT vulnerable to injection:
// If user_input = "{{ evil }}", output is literally "{{ evil }}"Risk: Malicious inputs could cause resource exhaustion.
Mitigations:
| Attack Vector | Mitigation |
|---|---|
| Deeply nested inheritance | Maximum depth limit (32 levels) |
| Circular dependencies | Cycle detection before processing |
| Large module count | O(V+E) algorithm scales linearly |
| Glob explosion | Glob patterns validated, finite expansion |
| Large file sizes | Reasonable limits on frontmatter size |
Risk: Sensitive information in context variables could leak.
Mitigations:
- Output goes to stdout/file controlled by user
- No telemetry or external reporting
- No logging of context values by default
User responsibilities:
- Do not put secrets (API keys, passwords) in context variables
- Review output before sharing or committing
- Use
.gitignoreto exclude sensitive files
fn validate_module(path: &Path, content: &str) -> Result<Module, ValidationError> {
// File must be UTF-8
// (Rust strings are already UTF-8)
// Frontmatter must be valid TOML
let frontmatter = parse_frontmatter(content)?;
// Validate frontmatter fields
if let Some(id) = &frontmatter.id {
validate_module_id(id)?;
}
if let Some(order) = frontmatter.order {
if order < 0 {
return Err(ValidationError::NegativeOrder { order });
}
}
// Validate dependency references
for dep in frontmatter.requires.iter()
.chain(frontmatter.suggests.iter())
.chain(frontmatter.conflicts.iter())
{
validate_module_id(dep)?;
}
Ok(Module::from_frontmatter(path, frontmatter, content))
}fn validate_preset(content: &str) -> Result<Preset, ValidationError> {
let preset: PresetFile = toml::from_str(content)?;
// Validate preset ID
validate_preset_id(&preset.preset.id)?;
// Validate inheritance references
for parent in &preset.preset.extends {
validate_preset_id(parent)?;
}
// Validate include/exclude patterns
for pattern in preset.modules.include.iter()
.chain(preset.modules.exclude.iter())
{
validate_glob_pattern(pattern)?;
}
Ok(Preset::from_file(preset))
}fn validate_glob_pattern(pattern: &str) -> Result<(), ValidationError> {
// Reject path traversal in patterns
if pattern.contains("..") {
return Err(ValidationError::PathTraversalInGlob { pattern: pattern.into() });
}
// Verify pattern compiles
globset::Glob::new(pattern)
.map_err(|e| ValidationError::InvalidGlob {
pattern: pattern.into(),
error: e.to_string(),
})?;
Ok(())
}fn validate_context_key(key: &str) -> Result<(), ValidationError> {
// Keys must be valid identifiers
if key.is_empty() {
return Err(ValidationError::EmptyContextKey);
}
if !key.chars().next().unwrap().is_alphabetic() && key.chars().next() != Some('_') {
return Err(ValidationError::InvalidContextKey { key: key.into() });
}
if !key.chars().all(|c| c.is_alphanumeric() || c == '_') {
return Err(ValidationError::InvalidContextKey { key: key.into() });
}
Ok(())
}MiniJinja templates are sandboxed by default:
| Capability | Status | Notes |
|---|---|---|
| Filesystem access | Disabled | No include, no file operations |
| Code execution | Disabled | No exec, no Python interop |
| External commands | Disabled | No shell access |
| Network access | Disabled | No HTTP/socket operations |
| Object introspection | Limited | Only exposed attributes |
Only safe, built-in filters are enabled:
default(value)— Fallback for undefinedupper,lower— Case conversiontrim— Whitespace removallength— Collection sizefirst,last— Collection accessjoin(sep)— Array joiningreplace(old, new)— String replacement
let env = Environment::new();
// No auto-escaping (output is markdown, not HTML)
env.set_auto_escape_callback(|_| AutoEscape::None);
// No undefined behavior - strict mode
env.set_undefined_behavior(UndefinedBehavior::Strict);
// No debug info in output
env.set_debug(false);plait reads from:
- Configured module directories
- Configured preset directories
- Project config (
./plait.toml) - User config (
~/.config/plait/config.toml)
All paths are validated and canonicalized.
plait writes to:
- stdout (default)
- User-specified output file (
--output)
No writes to:
- Module/preset directories
- Config files
- System directories
fn discover_modules(root: &Path) -> Result<Vec<PathBuf>, DiscoveryError> {
let mut visited = HashSet::new();
for entry in WalkDir::new(root).follow_links(true) {
let entry = entry?;
let canonical = entry.path().canonicalize()?;
// Detect symlink cycles
if !visited.insert(canonical.clone()) {
warn!("Symlink cycle detected, skipping: {}", entry.path().display());
continue;
}
// Process file...
}
Ok(modules)
}Errors never expose:
- Full filesystem paths outside configured directories
- System information beyond what's necessary
- Internal state that could aid attacks
// Good: Sanitized error
Error::ModuleNotFound {
id: "security/auth".into(),
searched_paths: vec!["~/.config/plait/modules".into()],
}
// Bad: Leaks system information
Error::ModuleNotFound {
id: "security/auth".into(),
searched_paths: vec!["/home/alice/.config/plait/modules".into()],
}- Review third-party modules before adding to your library
- Don't commit secrets in context variables or module content
- Use separate presets for different security contexts
- Review compiled output before sharing or publishing
- Don't include sensitive defaults in modules
- Use
default()filter for optional variables - Document required context variables clearly
- Avoid hardcoded paths in module content
# Check for known vulnerabilities
cargo audit
# Check license compliance
cargo deny check licenses
# Check for yanked crates
cargo deny check advisories- Minimize dependency count
- Prefer well-maintained crates
- Pin versions in
Cargo.lock - Regular security updates
If you discover a security issue:
- Do not open a public issue
- Email the maintainer directly
- Include reproduction steps and impact assessment
- Allow reasonable time for a fix before disclosure
Response timeline:
- Acknowledgment: 48 hours
- Initial assessment: 7 days
- Fix or mitigation: 30 days (90 for complex issues)