Skip to content

Latest commit

 

History

History
380 lines (284 loc) · 10.9 KB

File metadata and controls

380 lines (284 loc) · 10.9 KB

plait Security

Security considerations, threat model, and input validation.

Threat Model

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.

Trust Boundaries

┌─────────────────────────────────────────────────────────────┐
│                      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

Security Properties

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

Threat Categories

Path Traversal

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(())
}

Template Injection

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 }}"

Denial of Service

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

Information Disclosure

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 .gitignore to exclude sensitive files

Input Validation

Module 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))
}

Preset Files

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))
}

Glob Patterns

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(())
}

Context Variables

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 Sandboxing

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

Available Filters

Only safe, built-in filters are enabled:

  • default(value) — Fallback for undefined
  • upper, lower — Case conversion
  • trim — Whitespace removal
  • length — Collection size
  • first, last — Collection access
  • join(sep) — Array joining
  • replace(old, new) — String replacement

Disabled Features

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);

Filesystem Access

Read Access

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.

Write Access

plait writes to:

  • stdout (default)
  • User-specified output file (--output)

No writes to:

  • Module/preset directories
  • Config files
  • System directories

Symlink Handling

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)
}

Error Handling

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()],
}

Security Recommendations

For Users

  1. Review third-party modules before adding to your library
  2. Don't commit secrets in context variables or module content
  3. Use separate presets for different security contexts
  4. Review compiled output before sharing or publishing

For Module Authors

  1. Don't include sensitive defaults in modules
  2. Use default() filter for optional variables
  3. Document required context variables clearly
  4. Avoid hardcoded paths in module content

Dependency Security

Audit Process

# Check for known vulnerabilities
cargo audit

# Check license compliance
cargo deny check licenses

# Check for yanked crates
cargo deny check advisories

Dependency Principles

  • Minimize dependency count
  • Prefer well-maintained crates
  • Pin versions in Cargo.lock
  • Regular security updates

Reporting Vulnerabilities

If you discover a security issue:

  1. Do not open a public issue
  2. Email the maintainer directly
  3. Include reproduction steps and impact assessment
  4. 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)