mirror of
https://github.com/getAlby/hub.git
synced 2026-08-13 12:33:39 +02:00
fix: validate LND and CLN credential files during setup
The setup API accepts file paths for the LND certificate and macaroon and for the CLN lightning directory. Previously the raw file contents were read and stored without any validation. Validate these inputs before persisting them: - LND cert: parse the PEM and store only the re-encoded certificate(s), discarding any other PEM blocks (e.g. a bundled private key). - LND macaroon: unmarshal and store the re-marshalled macaroon. - CLN lightning directory: verify it contains the TLS credentials (ca.pem, client.pem, client-key.pem) that CLN loads at connect time, including the hold subdirectory when configured. On failure, return a generic error to the client and log the detail server-side. File paths remain supported for Umbrel-style installs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
ffee8cbcbe
commit
30fbc7dded
2 changed files with 289 additions and 8 deletions
136
api/api.go
136
api/api.go
|
|
@ -2,8 +2,11 @@ package api
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
|
|
@ -11,6 +14,7 @@ import (
|
|||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -18,6 +22,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"gopkg.in/macaroon.v2"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
|
|
@ -1779,12 +1784,18 @@ func (api *api) Setup(ctx context.Context, setupRequest *SetupRequest) error {
|
|||
}
|
||||
}
|
||||
if setupRequest.LNDCertFile != "" {
|
||||
certBytes, err := os.ReadFile(setupRequest.LNDCertFile)
|
||||
// The file path is provided by the (unauthenticated) setup request, so
|
||||
// only persist the content if it parses as a certificate. Storing the
|
||||
// re-encoded certificate(s) guarantees nothing but the parsed structure
|
||||
// reaches the database - e.g. a private key bundled in the same PEM file
|
||||
// is dropped rather than persisted.
|
||||
certHex, err := readAndCanonicalizeLNDCert(setupRequest.LNDCertFile)
|
||||
if err != nil {
|
||||
logger.Logger.WithError(err).Error("Failed to read lnd cert file")
|
||||
return err
|
||||
// Return a generic error and log the detail server-side so the
|
||||
// response is not a file existence/readability oracle.
|
||||
logger.Logger.WithError(err).Error("Failed to process lnd cert file")
|
||||
return errors.New("invalid LND certificate file")
|
||||
}
|
||||
certHex := hex.EncodeToString(certBytes)
|
||||
err = api.cfg.SetUpdate("LNDCertHex", certHex, setupRequest.UnlockPassword)
|
||||
if err != nil {
|
||||
logger.Logger.WithError(err).Error("Failed to save lnd cert hex")
|
||||
|
|
@ -1792,12 +1803,17 @@ func (api *api) Setup(ctx context.Context, setupRequest *SetupRequest) error {
|
|||
}
|
||||
}
|
||||
if setupRequest.LNDMacaroonFile != "" {
|
||||
macaroonBytes, err := os.ReadFile(setupRequest.LNDMacaroonFile)
|
||||
// The file path is provided by the (unauthenticated) setup request, so
|
||||
// only persist the content if it parses as a macaroon. Storing the
|
||||
// re-marshalled macaroon guarantees only the parsed structure reaches
|
||||
// the database.
|
||||
macaroonHex, err := readAndCanonicalizeLNDMacaroon(setupRequest.LNDMacaroonFile)
|
||||
if err != nil {
|
||||
logger.Logger.WithError(err).Error("Failed to read lnd macaroon file")
|
||||
return err
|
||||
// Return a generic error and log the detail server-side so the
|
||||
// response is not a file existence/readability oracle.
|
||||
logger.Logger.WithError(err).Error("Failed to process lnd macaroon file")
|
||||
return errors.New("invalid LND macaroon file")
|
||||
}
|
||||
macaroonHex := hex.EncodeToString(macaroonBytes)
|
||||
err = api.cfg.SetUpdate("LNDMacaroonHex", macaroonHex, setupRequest.UnlockPassword)
|
||||
if err != nil {
|
||||
logger.Logger.WithError(err).Error("Failed to save lnd macaroon hex")
|
||||
|
|
@ -1837,6 +1853,15 @@ func (api *api) Setup(ctx context.Context, setupRequest *SetupRequest) error {
|
|||
}
|
||||
|
||||
if setupRequest.CLNLightningDir != "" {
|
||||
// The directory path is provided by the (unauthenticated) setup request.
|
||||
// Validate that it holds the expected CLN TLS credentials before saving,
|
||||
// so the path cannot be used as an existence/readability oracle for
|
||||
// arbitrary directories (the failure otherwise surfaces via startupError
|
||||
// on the anonymous /api/info response).
|
||||
if err := validateCLNLightningDir(setupRequest.CLNLightningDir, setupRequest.CLNAddressHold != ""); err != nil {
|
||||
logger.Logger.WithError(err).Error("Failed to validate CLN lightning directory")
|
||||
return errors.New("invalid CLN lightning directory")
|
||||
}
|
||||
err = api.cfg.SetUpdate("CLNLightningDir", setupRequest.CLNLightningDir, setupRequest.UnlockPassword)
|
||||
if err != nil {
|
||||
logger.Logger.WithError(err).Error("Failed to save CLN Lightning directory path")
|
||||
|
|
@ -1855,6 +1880,101 @@ func (api *api) Setup(ctx context.Context, setupRequest *SetupRequest) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// readAndCanonicalizeLNDCert reads the LND TLS certificate at the given path,
|
||||
// validates that it contains at least one parseable certificate, and returns
|
||||
// the hex-encoded re-encoding of only the parsed certificate(s). Any non
|
||||
// CERTIFICATE PEM blocks (e.g. a bundled private key) are discarded so they are
|
||||
// never persisted. Callers must not reflect the returned error to the client.
|
||||
func readAndCanonicalizeLNDCert(path string) (string, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read LND cert file: %w", err)
|
||||
}
|
||||
|
||||
var canonical []byte
|
||||
rest := raw
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse LND certificate: %w", err)
|
||||
}
|
||||
canonical = append(canonical, pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE",
|
||||
Bytes: cert.Raw,
|
||||
})...)
|
||||
}
|
||||
if len(canonical) == 0 {
|
||||
return "", errors.New("no valid certificate found in LND cert file")
|
||||
}
|
||||
|
||||
return hex.EncodeToString(canonical), nil
|
||||
}
|
||||
|
||||
// readAndCanonicalizeLNDMacaroon reads the LND macaroon at the given path,
|
||||
// validates that it is a well-formed macaroon, and returns the hex-encoded
|
||||
// re-marshalling so that only the parsed structure is persisted. Callers must
|
||||
// not reflect the returned error to the client.
|
||||
func readAndCanonicalizeLNDMacaroon(path string) (string, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read LND macaroon file: %w", err)
|
||||
}
|
||||
|
||||
mac := &macaroon.Macaroon{}
|
||||
if err := mac.UnmarshalBinary(raw); err != nil {
|
||||
return "", fmt.Errorf("failed to parse LND macaroon: %w", err)
|
||||
}
|
||||
canonical, err := mac.MarshalBinary()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal LND macaroon: %w", err)
|
||||
}
|
||||
|
||||
return hex.EncodeToString(canonical), nil
|
||||
}
|
||||
|
||||
// validateCLNLightningDir checks that the given directory holds the CLN TLS
|
||||
// credentials that will later be loaded at connect time (ca.pem, client.pem,
|
||||
// client-key.pem), for each gRPC server name the config will use. This mirrors
|
||||
// the parses performed by the CLN client's loadTLSCredentials so a directory
|
||||
// that passes here is one CLN can actually use. Callers must not reflect the
|
||||
// returned error to the client.
|
||||
func validateCLNLightningDir(lightningDir string, hold bool) error {
|
||||
// "cln" reads the directory directly; other server names are joined as a
|
||||
// subdirectory, matching loadTLSCredentials in lnclient/cln.
|
||||
serverNames := []string{"cln"}
|
||||
if hold {
|
||||
serverNames = append(serverNames, "hold")
|
||||
}
|
||||
|
||||
for _, serverName := range serverNames {
|
||||
dir := lightningDir
|
||||
if serverName != "cln" {
|
||||
dir = filepath.Join(dir, serverName)
|
||||
}
|
||||
|
||||
caPEM, err := os.ReadFile(filepath.Join(dir, "ca.pem"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read CLN CA cert (%s): %w", serverName, err)
|
||||
}
|
||||
if !x509.NewCertPool().AppendCertsFromPEM(caPEM) {
|
||||
return fmt.Errorf("failed to parse CLN CA cert (%s)", serverName)
|
||||
}
|
||||
if _, err := tls.LoadX509KeyPair(filepath.Join(dir, "client.pem"), filepath.Join(dir, "client-key.pem")); err != nil {
|
||||
return fmt.Errorf("failed to load CLN client cert/key (%s): %w", serverName, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (api *api) GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesResponse, error) {
|
||||
lnClient := api.svc.GetLNClient()
|
||||
if lnClient == nil {
|
||||
|
|
|
|||
161
api/setup_test.go
Normal file
161
api/setup_test.go
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
crand "crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/macaroon.v2"
|
||||
)
|
||||
|
||||
// generateTestCert returns a self-signed certificate PEM block and its
|
||||
// matching EC private key PEM block.
|
||||
func generateTestCert(t *testing.T) (certPEM []byte, keyPEM []byte) {
|
||||
t.Helper()
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), crand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "test"},
|
||||
NotBefore: time.Unix(0, 0),
|
||||
NotAfter: time.Unix(1<<31, 0),
|
||||
}
|
||||
der, err := x509.CreateCertificate(crand.Reader, &template, &template, &key.PublicKey, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
require.NoError(t, err)
|
||||
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||
|
||||
return certPEM, keyPEM
|
||||
}
|
||||
|
||||
func TestReadAndCanonicalizeLNDCert(t *testing.T) {
|
||||
certPEM, keyPEM := generateTestCert(t)
|
||||
dir := t.TempDir()
|
||||
|
||||
t.Run("valid certificate", func(t *testing.T) {
|
||||
path := filepath.Join(dir, "tls.cert")
|
||||
require.NoError(t, os.WriteFile(path, certPEM, 0600))
|
||||
|
||||
got, err := readAndCanonicalizeLNDCert(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
raw, err := hex.DecodeString(got)
|
||||
require.NoError(t, err)
|
||||
require.True(t, x509.NewCertPool().AppendCertsFromPEM(raw))
|
||||
})
|
||||
|
||||
t.Run("bundled private key is stripped", func(t *testing.T) {
|
||||
path := filepath.Join(dir, "bundle.pem")
|
||||
require.NoError(t, os.WriteFile(path, append(append([]byte{}, certPEM...), keyPEM...), 0600))
|
||||
|
||||
got, err := readAndCanonicalizeLNDCert(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
raw, err := hex.DecodeString(got)
|
||||
require.NoError(t, err)
|
||||
// Only the CERTIFICATE block must survive - the private key must not
|
||||
// be persisted.
|
||||
require.NotContains(t, string(raw), "PRIVATE KEY")
|
||||
require.Contains(t, string(raw), "CERTIFICATE")
|
||||
})
|
||||
|
||||
t.Run("arbitrary non-cert file is rejected", func(t *testing.T) {
|
||||
path := filepath.Join(dir, "secret.txt")
|
||||
require.NoError(t, os.WriteFile(path, []byte("root:x:0:0:root:/root:/bin/bash\n"), 0600))
|
||||
|
||||
_, err := readAndCanonicalizeLNDCert(path)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("missing file is rejected", func(t *testing.T) {
|
||||
_, err := readAndCanonicalizeLNDCert(filepath.Join(dir, "does-not-exist"))
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestReadAndCanonicalizeLNDMacaroon(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
t.Run("valid macaroon", func(t *testing.T) {
|
||||
mac, err := macaroon.New([]byte("root-key"), []byte("id"), "location", macaroon.LatestVersion)
|
||||
require.NoError(t, err)
|
||||
raw, err := mac.MarshalBinary()
|
||||
require.NoError(t, err)
|
||||
|
||||
path := filepath.Join(dir, "admin.macaroon")
|
||||
require.NoError(t, os.WriteFile(path, raw, 0600))
|
||||
|
||||
got, err := readAndCanonicalizeLNDMacaroon(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
gotRaw, err := hex.DecodeString(got)
|
||||
require.NoError(t, err)
|
||||
roundTrip := &macaroon.Macaroon{}
|
||||
require.NoError(t, roundTrip.UnmarshalBinary(gotRaw))
|
||||
})
|
||||
|
||||
t.Run("arbitrary non-macaroon file is rejected", func(t *testing.T) {
|
||||
path := filepath.Join(dir, "id_rsa")
|
||||
require.NoError(t, os.WriteFile(path, []byte("-----BEGIN OPENSSH PRIVATE KEY-----\nsecret\n"), 0600))
|
||||
|
||||
_, err := readAndCanonicalizeLNDMacaroon(path)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("missing file is rejected", func(t *testing.T) {
|
||||
_, err := readAndCanonicalizeLNDMacaroon(filepath.Join(dir, "does-not-exist"))
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateCLNLightningDir(t *testing.T) {
|
||||
certPEM, keyPEM := generateTestCert(t)
|
||||
|
||||
writeCLNDir := func(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "ca.pem"), certPEM, 0600))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "client.pem"), certPEM, 0600))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "client-key.pem"), keyPEM, 0600))
|
||||
}
|
||||
|
||||
t.Run("valid directory", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeCLNDir(t, dir)
|
||||
require.NoError(t, validateCLNLightningDir(dir, false))
|
||||
})
|
||||
|
||||
t.Run("valid directory with hold subdirectory", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeCLNDir(t, dir)
|
||||
holdDir := filepath.Join(dir, "hold")
|
||||
require.NoError(t, os.Mkdir(holdDir, 0700))
|
||||
writeCLNDir(t, holdDir)
|
||||
require.NoError(t, validateCLNLightningDir(dir, true))
|
||||
})
|
||||
|
||||
t.Run("hold requested but subdirectory missing", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeCLNDir(t, dir)
|
||||
require.Error(t, validateCLNLightningDir(dir, true))
|
||||
})
|
||||
|
||||
t.Run("arbitrary directory is rejected", func(t *testing.T) {
|
||||
require.Error(t, validateCLNLightningDir(t.TempDir(), false))
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue