This document describes the security properties of OpenLore and the design decisions behind them.
OpenLore is a primarily read-only SSH server that serves a virtual filesystem. The optional publish command provides controlled write access to configured docsets. It is designed to be safe to expose on internal networks and, with public key auth enabled, on the public internet. The primary threat actors are:
- Malicious SSH clients attempting to escape the sandbox
- Agents or users attempting to access files outside their allowed scope
- Denial-of-service attacks against the SSH server
Commands are parsed using a structural tokenizer, not by passing strings to a shell. There is no shell interpolation, no backtick expansion, no $(...) substitution, and no glob expansion by the parser.
No shell injection is possible because:
- Commands are tokenized into
[]stringarguments - Each command name is matched against a fixed allowlist of built-in functions
- Unknown commands return an error — they are never passed to
os/execor any system shell - Pipe chains are handled by splitting on
|and connecting in-memory buffers
The VFS layer provides a read-only view over an fs.FS (either os.DirFS or embed.FS).
Path traversal protection:
- All paths are processed through
path.Cleanto normalize.and..components - Resolved paths are checked to ensure they remain within the VFS root
- Symlinks pointing outside the root are not followed
Read-only enforcement:
- The VFS interface only exposes
Open,ReadDir, andStat - No write, rename, delete, or permission-change operations exist in the interface
- File handles returned are read-only
- The
publishcommand is the sole exception: it writes to a configuredpublish_diron disk, not to the VFS directly - Write access is opt-in per docset via
publish_dirinlore.json— by default, no docsets are writable
File filtering:
- Allowed patterns (e.g.,
*.md,*.txt) restrict which files are visible - Ignore patterns (e.g.,
.git,.env) hide directories and files entirely - Filtering is applied at the VFS level — filtered files don't appear in
ls,find,tree, orcat
HTML, CSS, and JavaScript in default allowed patterns:
- The default allowed patterns include
*.html,*.htm,*.css, and*.jsto support serving rich documentation (e.g., generated API docs, interactive diagrams, single-page doc sites) - Over SSH, this is low-risk — agents receive raw file content as text, not rendered HTML
- Over the HTTP lore browser (passkeys), HTML files are rendered by the browser. Since publishing requires SSH authentication and only trusted identities should have access (
unknown_identity: "deny"), the content itself is trusted - The primary risk is MITM on the HTTP side: if the lore browser is served over plain HTTP, an attacker could modify HTML/JS in transit to inject malicious scripts. The SSH upload path is encrypted and not vulnerable
- Mitigation: Serve the HTTP lore browser over TLS (
tls_cert/tls_key) to prevent tampering in transit
SSH transport is handled by charmbracelet/ssh, which wraps golang.org/x/crypto/ssh.
Properties:
- Standard SSH key exchange and encryption (curve25519-sha256, aes256-gcm, etc.)
- Host key identity via ed25519 key (auto-generated on first run)
- Public key authentication validated by
golang.org/x/crypto/sshkey parsing - No password authentication (even in keyless mode — the server accepts all keys, it doesn't use passwords)
The SFTP server provides filesystem access for direct editor integrations and
read-only sshfs mounts.
Enforcement:
Open(read),ReadDir, andStatuse the identity-scoped session VFS, so file filtering and path protection apply- A writable file handle stages offset writes privately and submits one atomic whole-file write when the handle closes
- The commit passes through the same identity grants, admission middleware, validation, approvals, size limits, ordered write log, and compare-and-swap protection as shell writes
- An interrupted transfer is discarded, and a concurrent change makes the close fail instead of overwriting newer content
- Namespace and metadata operations (
Remove,Rename,Mkdir,Chmod, etc.) remain unsupported and return permission denied
The bash interpreter is not bash. It is a set of Go functions that implement common read-only Unix commands:
| Command | Implementation |
|---|---|
ls |
Calls fs.ReadDir on the VFS |
cat |
Calls fs.Open and reads the file |
grep |
Compiles a regexp.Regexp and scans lines |
find |
Walks the VFS with fs.WalkDir |
tree |
Recursive fs.ReadDir with formatting |
head/tail |
Reads lines from a file buffer |
wc |
Counts bytes/lines in a buffer |
stat |
Calls fs.Stat on the VFS |
cd/pwd |
Tracks working directory in session state |
No process execution:
- No use of
os/exec,syscall.Exec, or any process spawning bash,sh,/bin/sh,exec, and similar commands are not recognized- Environment variables are not expanded (no
$HOME,$PATH, etc.)
Keyless mode (default):
- All SSH connections are accepted regardless of key
- The user field from the SSH session is available but not enforced
- Suitable for local development and trusted networks
Public key mode:
- Public keys in
auth.jsonare matched against the client's presented key - Per-identity folder restrictions limit which VFS paths are accessible
- Keys are validated by
golang.org/x/crypto/ssh.ParseAuthorizedKey - Unknown keys are rejected at the SSH handshake level
Certificate authority mode (user certificates):
- When
ca_keys_fileis configured, the server accepts SSH user certificates signed by trusted CA keys - Analogous to OpenSSH's
TrustedUserCAKeysdirective - The CA's public key is stored on the server; users present certificates signed by that CA
- Certificate validity (expiry, principals) is enforced by
golang.org/x/crypto/ssh - Can be combined with public key auth — certificates and raw keys are both accepted
Host certificates (server identity):
- When
host_cert_fileis configured, the server presents a CA-signed host certificate to clients - Analogous to OpenSSH's
HostCertificatedirective - Clients configure
@cert-authorityinknown_hoststo trust all hosts signed by the CA - Limitation: still relies on TOFU for CA trust distribution. The client must obtain and configure the CA public key before the first connection. If an attacker intercepts the first connection before the client has the CA key, they can present a fraudulent server. SSH has no equivalent of the TLS WebPKI or Certificate Transparency — there is no global, independently-auditable registry of host CA keys. In practice, this means:
- The CA public key must be distributed through a trusted out-of-band channel (e.g., internal wiki, config management, signed package, or a well-known HTTPS endpoint)
- Publishing the CA public key at a stable, publicly-accessible HTTPS URL (e.g.,
https://example.com/.well-known/ssh-ca.pub) and documenting it in your README is strongly recommended. HTTPS provides the independent trust anchor that SSH lacks - For automated agent deployments, bake the CA public key into the agent's image or configuration rather than relying on first-connection trust
Passkeys provide browser-based access to documentation for humans, using the WebAuthn standard (Face ID, Touch ID, security keys).
Registration flow:
- Registration is initiated from the SSH shell (
passkey register), which creates a one-time token stored in memory - The token is embedded in a URL (e.g.,
/passkey/r/{token}) with a 5-minute TTL - The URL serves a page that calls
navigator.credentials.create()— the WebAuthn browser API - The server validates the attestation using go-webauthn/webauthn, a FIDO2-conformant library
- On success, the credential (public key, credential ID, sign count) is persisted to a JSON file on disk
Authentication flow:
- Uses WebAuthn discoverable credentials (resident keys) — no username required
- The server generates a random challenge via
BeginDiscoverableLogin() - The browser calls
navigator.credentials.get()and the authenticator signs the challenge - The server verifies the signature against stored public keys and checks the sign count to detect cloned authenticators
- On success, an HMAC-signed session cookie is set
Session cookies:
- Payload:
lore_name:expiry_unix, signed with HMAC-SHA256 - The HMAC key is derived from the SSH host key material via
SHA-256("openlore-passkey-session:" || host_key_bytes)— this means sessions are invalidated if the host key changes - The key derivation ensures raw host key material is never used directly as an HMAC key
- Cookies are
HttpOnly,SameSite=Lax, and have an expiry matching the configured session TTL (default 24h) - No server-side session state — validation is purely cryptographic
Credential storage:
- Credentials are stored in a plain JSON file (
./config/passkeys.json) with0600permissions - The file contains WebAuthn credential data (public keys, credential IDs, sign counts) — no secrets
- Designed for agent editability: an agent can manage passkeys by reading/writing this file
Pending registrations:
- Stored in-memory only (a
sync.Mapwith TTL) — not persisted across restarts - Expired tokens are garbage-collected every 60 seconds
- Each token is 32 bytes of
crypto/randoutput, hex-encoded (256 bits of entropy)
WebAuthn security properties:
- Requires a secure context:
https://orlocalhost(enforced by browsers, not by OpenLore) - The Relying Party ID (
rp_id) must match the domain the user visits — browsers enforce this RPOriginsmust be explicitly configured and match the HTTP server's actual origin- Resident key requirement is set to
Required, ensuring discoverable credentials for usernameless login - Attestation is validated but not filtered by format (any authenticator is accepted)
Threat considerations:
| Threat | Mitigation |
|---|---|
| Registration link interception | Tokens expire in 5 minutes; one-time use; should be served over TLS |
| Session cookie theft | HttpOnly prevents JS access; SameSite=Lax prevents CSRF; TLS prevents network sniffing |
| Credential file tampering | File has 0600 permissions; an attacker with disk write access could add credentials, but they'd need a corresponding authenticator private key to actually authenticate |
| Replay attacks | WebAuthn challenges are single-use random values; sign count tracking detects cloned authenticators |
| Phishing | WebAuthn is origin-bound — the browser will not sign challenges for a different domain |
Limitations:
- No built-in CSRF protection on the WebAuthn API endpoints beyond
SameSitecookies and WebAuthn's own origin checking - The login session cookie (used during the begin/finish ceremony) is stored in server memory — high concurrency could grow this map. Entries expire after 5 minutes.
- No revocation propagation — revoking a passkey from
passkeys.jsontakes effect on the next authentication attempt, but existing session cookies remain valid until they expire
The access control model separates docsets (atomic document collections with path lists) from lore (named compositions of docsets). Each identity — whether an SSH key or a passkey — references a lore name.
Path isolation:
- When a lore resolves to multiple docsets, each docset is served as a separate subdirectory in the HTTP browser (e.g.,
/lore/backend/,/lore/frontend/) - Path traversal between docsets is not possible because each docset's VFS paths are resolved independently against the server's root filesystem
- The VFS path cleaning (
path.Clean) applies before docset resolution
Naming: Docset names and lore names occupy separate namespaces in lore.json, so naming conflicts are not possible.
OpenLore does not include built-in rate limiting or connection limits. This is by design — these concerns are better handled at the infrastructure level:
- Use a reverse proxy or load balancer for connection rate limiting
- Use OS-level limits (
ulimit,systemdresource controls) for per-process constraints - Use firewall rules to restrict source IPs
Resource considerations:
- Each session maintains an in-memory working directory path (minimal memory)
- File reads are streaming (not buffered entirely in memory)
grep -randfindon large filesystems will consume CPU proportional to the number of files- The VFS does not cache file contents — each read goes to disk (or embed.FS)
The publish command takes two arguments (<docset> and <path>), reads content from stdin, and writes it to a directory on disk.
Write scope:
- Publishing is disabled by default — docsets must explicitly set
publish_dirto enable writes - Each writable docset maps to a single directory on disk
- The
publishcommand is the only write path in the system; no other command can create, modify, or delete files
Path traversal protection:
- Input paths are cleaned with
path.Clean("/" + path)and..segments are rejected - The resolved disk path is checked with
filepath.Absto ensure it remains within the targetpublish_dir - Both the VFS-level cleaning and the disk-level prefix check apply (defense in depth)
Content handling:
- Content is read entirely from stdin before writing (no streaming writes)
- Parent directories are created with
os.MkdirAll(dir, 0755) - Files are written with
os.WriteFile(path, content, 0644) - No content validation is performed — the
publishcommand writes whatever is piped to it. File type filtering (allowed/denied patterns) applies when the file is served, not when it is written
Identity scoping:
- Publish targets are registered at server startup from the auth config
- Currently, any connected identity can publish to any writable docset. Per-identity write scoping is not yet implemented — use network-level controls and
unknown_identity: "deny"to restrict who can connect.
Threat considerations:
| Threat | Mitigation |
|---|---|
| Path traversal | path.Clean + .. rejection + filepath.Abs prefix check |
| Disk exhaustion | Per-docset max_publish_size (default 2.5MB) limits individual writes. Use OS-level disk quotas for aggregate limits |
| MITM tampering with served HTML | If the HTTP lore browser is not TLS-secured, an attacker could modify HTML/JS in transit. Serve over TLS |
| Overwriting existing files | Allowed by design — os.WriteFile overwrites |
| Writing executable content | Files are only served through the VFS with file type filtering. The server does not execute uploaded files |
| Unauthorized writes | Requires SSH authentication. Use unknown_identity: "deny" to restrict connections |
skills import and the Agent Skills sync path are the only features that make outbound network requests. They fetch public git repositories (ref advertisements and source archives) from GitHub, GitLab, Bitbucket, Codeberg, and self-hosted GitLab/Gitea/Forgejo instances.
SSRF protection (egress policy):
- All requests use a restricted HTTP client (
NewPublicHTTPClient) whose dialer resolves DNS and connects only to public IP addresses — loopback, RFC 1918 private, link-local, CGNAT, and other special-purpose ranges are rejected - The vetted IP is dialed directly, so a DNS rebind between check and connect has no effect; TLS server name verification still applies
- Redirects are followed only to HTTPS URLs, and every hop passes through the same public-IP dialer
- Only
https://repository URLs are accepted; userinfo, query strings, and fragments are rejected during spec parsing
Content handling:
- No credentials are ever sent — only public repositories are reachable
- Archives are bounded by compressed-size, decompressed-size, file-count, and entry-count limits before extraction
- Archive entries are checked for path traversal, duplicates, and file/directory conflicts; symlinks and hard links are skipped
- Fetched
SKILL.mdcontent is normalized and fully validated before anything is written to a collection; advertised ref object IDs must be 40-hex SHAs
Threat considerations:
| Threat | Mitigation |
|---|---|
| SSRF against internal services | Public-IP-only dialer applied to every connection and redirect hop |
| Redirect to internal host or plain HTTP | CheckRedirect enforces HTTPS; dialer re-applies IP policy per hop |
| Decompression bombs | Compressed and decompressed size limits, entry scan limits |
| Malicious archive paths | Path cleaning, traversal rejection, duplicate detection, symlink/hardlink skipping |
| Malicious skill content | Frontmatter normalization and strict validation before any write; skills are data, never executed by OpenLore |
- No rate limiting — must be handled externally
- No TLS termination — not needed, SSH provides encryption
- No audit logging — connection events are logged via slog, but no detailed command audit trail is built in (use the
OnConnecthook in library mode for custom auditing) - Symlink handling — symlinks within the served directory are followed; symlinks pointing outside are not. A determined attacker with control over the served directory could create symlinks to sensitive files. Only serve directories you trust.
- Large file reads —
caton a very large file will stream the entire content. There is no built-in size limit. Consider usingheadfor large files. - No built-in CA key distribution — SSH host certificates shift trust from individual host keys to a CA, but clients must still obtain the CA public key out-of-band before the first connection. Unlike TLS (where browsers ship with trusted root CAs), SSH has no pre-installed trust store. Publish your CA public key at a well-known HTTPS URL so clients can fetch and verify it independently.
- Passkey session cookies are not revocable — once issued, a session cookie is valid until its TTL expires. Revoking a passkey from
passkeys.jsonprevents new logins but does not invalidate existing sessions. Set a shortersession_ttlif this is a concern. - WebAuthn requires secure context — passkey registration and login only work over
https://orlocalhost. If you expose the HTTP server without TLS on a non-localhost address, browsers will refuse to call the WebAuthn API. - In-memory login sessions — the WebAuthn begin/finish ceremony state is held in a Go map. Under extreme concurrent login load this could grow; entries auto-expire after 5 minutes.
- No per-identity publish scoping — any authenticated user can publish to any writable docset. If you need identity-level write restrictions, use separate server instances.
- No aggregate publish quota — individual files are capped by
max_publish_size(default 2.5MB), but there is no limit on the total number or aggregate size of published files. Use OS-level disk quotas for untrusted environments. - No content validation — the
publishcommand writes any content. Malicious content will be served as-is if it matches the allowed file patterns. - No repository host allowlist —
skills importcan fetch from any public HTTPS host. The egress policy blocks internal addresses, but if you need to restrict which forges are reachable, use network-level egress filtering. Conversely, repositories on private networks (e.g., internal GitHub Enterprise) are unreachable by design.
- Enable public key auth in production: set
allow_keyless: falseand configurelore.json - Use file filtering to avoid serving sensitive file types
- Use ignore patterns to exclude
.git,.env,node_modules, and other non-doc content - Run behind a firewall or VPN for internal documentation servers
- Use
go:embedfor public-facing docs to eliminate filesystem access entirely - Publish your SSH CA public key over HTTPS if using host certificates. Host it at a stable URL (e.g.,
https://example.com/.well-known/ssh-ca.pub) so clients can verify the CA independently before their first SSH connection. This closes the TOFU gap that SSH certificates alone cannot solve. - Serve the HTTP server over TLS when using passkeys in production (
tls_cert/tls_keyinopenlore.yml). WebAuthn requires a secure context, and TLS protects registration URLs and session cookies in transit. - Use short session TTLs for passkeys in high-security environments. Since session cookies are not server-side revocable, a shorter TTL (e.g.,
1h) limits the window of exposure if a cookie is compromised. - Use docsets to scope passkey access — give each passkey the narrowest lore collection needed rather than
full-access. - Only enable
publish_diron docsets that should be writable — leave it unset on read-only documentation collections. - Set
unknown_identity: "deny"whenpublish_diris enabled to ensure only known identities can write. - Serve the lore browser over TLS when serving HTML/JS content to prevent MITM tampering in transit.
- Review imported skills before enabling agents on them — skill files are validated structurally, not semantically. Treat third-party skills like code: read
SKILL.mdand any referenced scripts, and prefer pinning imports to a tag or commit SHA over tracking a branch.