Skip to content

fix: path traversal in keystore v2 DirectoryBackend.osPath()#736

Open
zenmpi wants to merge 3 commits intocossacklabs:masterfrom
zenmpi:fix/security-and-bug-fixes
Open

fix: path traversal in keystore v2 DirectoryBackend.osPath()#736
zenmpi wants to merge 3 commits intocossacklabs:masterfrom
zenmpi:fix/security-and-bug-fixes

Conversation

@zenmpi
Copy link

@zenmpi zenmpi commented Feb 26, 2026

Summary

The path traversal check in DirectoryBackend.osPath() (keystore/v2/keystore/filesystem/backend/filesystem.go) is ineffective and does not prevent key path resolution outside the keystore root directory.

Vulnerability Details

The function constructs an OS path from a logical key path and validates it:

fullPath := filepath.Join(b.root, pathSeparators.Replace(path))
if fullPath != filepath.Clean(fullPath) {
    return "", api.ErrInvalidPath
}

According to the Go documentation, filepath.Join already returns a Clean-ed result:

Join joins any number of path elements into a single path [...] The result is Cleaned.

Therefore fullPath != filepath.Clean(fullPath) is always false, and the check never fires — even when the resolved path escapes the root directory.

Example

root:     /var/lib/acra/keys
input:    client/../../other-instance/keys/client/victim/storage.keyring
resolved: /var/lib/other-instance/keys/client/victim/storage.keyring  (outside root!)
check:    resolved != filepath.Clean(resolved) → false → allowed through

Affected Path

The keystore v1 (keystore/filesystem/) calls ValidateID() which restricts clientID to [a-zA-Z0-9_\- ], effectively blocking .. in paths. The v2 keystore does not call ValidateID(), relying solely on the osPath() check which is ineffective.

Impact

When clientID is taken directly from the gRPC request (acratranslator_client_id_from_connection_enable=false, the default), a crafted clientID containing .. sequences can cause the keystore to read keyring files from outside the root directory. The file name suffix (e.g. storage.keyring) is appended by the calling code and cannot be controlled, so this is limited to reading *.keyring files from arbitrary directories — not arbitrary file read.

When acratranslator_client_id_from_connection_enable=true, the clientID is extracted from the TLS peer certificate and is not attacker-controlled. The HTTP API always extracts clientID from the TLS connection and is not affected.

Fix

Two changes:

1. Sanitize root in both constructors (CreateDirectoryBackend, OpenDirectoryBackend):

return &DirectoryBackend{
    root: filepath.Clean(root),
    log:  newLog,
    lock: lock,
}, nil

This ensures root is always in canonical form. This also fixes a latent issue in ListAll() where strings.TrimPrefix(path, b.root+separator) needs root to match filepath.Walk output format — Walk returns cleaned paths, so if root was not cleaned, TrimPrefix could silently fail to strip the prefix.

2. Replace the tautological filepath.Clean comparison in osPath() with strings.HasPrefix:

if !strings.HasPrefix(fullPath, b.root+string(os.PathSeparator)) {
    return "", api.ErrInvalidPath
}

The + string(os.PathSeparator) suffix prevents partial prefix matches — e.g. a root of /keys must not match a path under /keys-backup.

Testing

Verified with path traversal inputs (../, ../../, client/../../) that:

  • Legitimate key paths within the root directory are allowed
  • Paths resolving outside the root are rejected
  • Edge cases (trailing slash on root, similar directory names like /keys-evil) are handled correctly
  • All existing backend tests pass (TestFilesystem, TestInMemory — Get/Put, ListAll, Separators, Rename, RenameNX, Locking)

## Vulnerability

The `osPath()` function in `DirectoryBackend` is responsible for
converting logical key paths into OS filesystem paths and ensuring
they remain within the keystore root directory.

The existing check compares `fullPath` with `filepath.Clean(fullPath)`:

    if fullPath != filepath.Clean(fullPath) {
        return "", api.ErrInvalidPath
    }

However, `fullPath` is produced by `filepath.Join()`, which according
to Go documentation already returns a Clean-ed result. Therefore the
condition `fullPath != filepath.Clean(fullPath)` is always false,
and the check never rejects any input — including paths containing
"..".

This allows a crafted key path (e.g. via `clientID` containing
"../../") to resolve to a file outside the keystore root directory.

Note: the keystore v1 (`keystore/filesystem/`) is not affected because
it calls `ValidateID()` before constructing paths, which restricts
input to `[a-zA-Z0-9_\- ]`. The v2 keystore does not call `ValidateID`.

## Impact

An attacker who can supply an arbitrary `clientID` through the gRPC API
(when `acratranslator_client_id_from_connection_enable` is false, which
is the default) can read keyring files (*.keyring) from arbitrary
directories on the server. The file name suffix is fixed by the calling
code (e.g. "storage.keyring"), so arbitrary file read is not possible,
but keys belonging to other Acra instances on the same host can be
accessed.

When `acratranslator_client_id_from_connection_enable` is true, the
clientID is extracted from the TLS certificate and cannot be controlled
by the caller. The HTTP API is also not affected because it always
derives clientID from the TLS connection.

## Fix

Replace the ineffective `filepath.Clean` comparison with a
`strings.HasPrefix` check that verifies the resolved path starts with
the normalized root directory prefix. `filepath.Clean(b.root)` is used
to handle edge cases where the root path may contain a trailing slash.
Move filepath.Clean(root) + PathSeparator computation from osPath()
into CreateDirectoryBackend and OpenDirectoryBackend constructors.
Store the result in the rootPrefix field to avoid redundant work
on every osPath() invocation.
Clean root with filepath.Clean() at construction time instead of
storing raw root alongside a separate rootPrefix. The HasPrefix
check in osPath() now uses b.root + separator directly.

This also fixes a latent issue in ListAll() where TrimPrefix on
line 352 needs root to match filepath.Walk output format.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants