Skip to content

[feature/security] file-substitution syntax for secrets in KDL #638

@stevelr

Description

@stevelr

If you're open to a PR I can submit one for this ..

Problem

It's better security practice to keep secrets out of KDL files - they shouldn't be on disk at all, and we definitely don't want to commit them to git.

To do this now, you need do a service startup replacement, like a systemd ExecStartPre that runs sed.
This has potential footguns: escaping bugs, ordering issues with secrets-fetch services, and unit restarts. Having ferron build in the substitution avoids those.

Use cases

  • Bearer-token auth:
    location "/api" {
      if_not (is_equal "{header:authorization}" "Bearer @@FILE:/run/secrets/api-token@@") {
        status 401
      }
    }
  • Basic-auth credentials in a custom condition or header.
  • Upstream auth headers:
    proxy "https://backend" {
      request_header "Authorization" "Bearer @@FILE:/run/secrets/upstream-token@@"
    }
  • Per-tenant API keys loaded from a directory of per-tenant files, where the static KDL references each by path.

The general form is: secret bytes live in a file (such as /run in tmpfs with appropriate permissions); the config references the file by path; the server expands the reference at load time and the secret never appears on disk in the merged config.

Prior art

  • Caddy: {file./path/to/file} placeholder (and {$ENV} for environment variables) — both expand at config-load time.
  • nginx: variable interpolation ($variable) plus include directives.
  • Apache httpd: Include and Define.
  • HAProxy: setenv and getenv directives.
  • Traefik: file-based providers and explicit secret references.

Ferron is the outlier in not having this.

Potential implementation

A new substitution token recognized by the KDL parser at config-load time. Suggested syntax:

@@FILE:/absolute/path@@

Semantics:

  • File contents read once at config load and on SIGHUP reload.
  • Trailing newline (\n or \r\n) trimmed by default — matches what cat of a typical password file produces and what the operator usually wants. Internal whitespace preserved verbatim.
  • Read errors (file missing, permission denied, non-UTF-8 if the consumer expects UTF-8) fail config load with a clear error pointing at the offending KDL line.
  • File path must be absolute. Relative paths are rejected to avoid CWD-dependent inconsistencies.
  • Optional: @@FILE_RAW:/path@@ to preserve trailing newline.

Syntax:

  • @@FILE... is just an example. I'm not opinionated as to the syntax. Other forms like {file:/path} could work.

Other benefits - besides the key point of better security

  1. Self-contained scope: the change is a preprocess pass over the loaded KDL string. It doesn't touch the auth subsystem, the proxy layer, the TLS layer, or the parser's KDL semantics. New surface is small.
  2. Aligns with file-based secret-management tools: sops-nix, secretspec, Kubernetes Secret volume mounts, AWS Parameter Store CSI drivers, HashiCorp Vault Agent injectors, Doppler, etc. all materialize secrets as files.

Alternatives considered

  • include directive for values. Less flexible — include typically pulls a whole config block, not a scalar value. Doesn't fit the use case where you want a single string substituted into a longer expression.
  • Environment-variable interpolation only (e.g. ${VAR}). Works for short scalar values but is awkward for multi-line values like cert content; also requires the operator to populate env vars, which has its own fragility (secret leaks via /proc/<pid>/environ). I did see an open issue for environment substitution. This is complementary and IMO preferable, although both could be implemented.
  • External preprocessor: like sed. It's what we have to do today, but all ferron users have to implement it
  • KDL custom value type: too invasive — KDL is a syntax-level language; value-substitution is more naturally a Ferron-level preprocess pass than a KDL extension.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request
    No fields configured for Feature.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions