Merge branch 'main' into invite-email-domain

This commit is contained in:
Alessandro (Ale) Segala
2026-07-17 16:36:33 -07:00
committed by GitHub
17 changed files with 1295 additions and 364 deletions
@@ -0,0 +1,55 @@
package bootstrap
import (
"fmt"
"log/slog"
"net"
"time"
"github.com/pires/go-proxyproto"
)
const proxyProtocolReadHeaderTimeout = 5 * time.Second
func newProxyProtocolListener(listener net.Listener, trustedProxies []string) (net.Listener, error) {
if len(trustedProxies) == 0 {
return listener, nil
}
// PROXY protocol transports client metadata on TCP connections before any TLS handshake
network := listener.Addr().Network()
if network != "tcp" && network != "tcp4" && network != "tcp6" {
return nil, fmt.Errorf("PROXY protocol requires a TCP listener, got %q", network)
}
// Require headers from configured network proxies and reject every other network peer
trustedProxyPolicy, err := proxyproto.TrustProxyHeaderFromRanges(trustedProxies)
if err != nil {
return nil, fmt.Errorf("failed to configure PROXY protocol trusted proxies: %w", err)
}
// We need to add another policy for loopback connections because healthcheck requests are made from the same host
// loopbackPolicy handles requests from trusted loopback like normal, but it also allows untrusted loopback connections to be accepted
// untrusted loopback connections aren't allowed to send PROXY headers though
loopbackPolicy, err := proxyproto.PolicyFromRanges(trustedProxies, proxyproto.USE, proxyproto.SKIP)
if err != nil {
return nil, fmt.Errorf("failed to configure PROXY protocol loopback proxies: %w", err)
}
// Create a policy which decides which of the two policies to use based on the upstream address
policy := func(options proxyproto.ConnPolicyOptions) (proxyproto.Policy, error) {
upstream, ok := options.Upstream.(*net.TCPAddr)
if ok && upstream.IP.IsLoopback() {
return loopbackPolicy(options)
}
return trustedProxyPolicy(options)
}
slog.Info("PROXY protocol enabled")
return &proxyproto.Listener{
Listener: listener,
ConnPolicy: policy,
ReadHeaderTimeout: proxyProtocolReadHeaderTimeout,
}, nil
}
@@ -0,0 +1,486 @@
package bootstrap
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"io"
"math/big"
"net"
"net/http"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/pires/go-proxyproto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewProxyProtocolListenerDisabled(t *testing.T) {
listener, _ := newProxyProtocolTestConnection(t, "192.0.2.10")
wrapped, err := newProxyProtocolListener(listener, nil)
require.NoError(t, err)
assert.Same(t, listener, wrapped)
}
func TestNewProxyProtocolListenerRequiresTCP(t *testing.T) {
serverConn, clientConn := net.Pipe()
t.Cleanup(func() {
_ = serverConn.Close()
_ = clientConn.Close()
})
listener := &singleConnListener{
conn: serverConn,
addr: &net.UnixAddr{Name: "/tmp/pocket-id.sock", Net: "unix"},
closed: make(chan struct{}),
}
_, err := newProxyProtocolListener(listener, []string{"127.0.0.1"})
require.Error(t, err)
assert.ErrorContains(t, err, "PROXY protocol requires a TCP listener")
}
func TestNewProxyProtocolListenerUsesStrictTrustedProxyPolicy(t *testing.T) {
listener, _ := newProxyProtocolTestConnection(t, "192.0.2.10")
wrapped, err := newProxyProtocolListener(listener, []string{"192.0.2.0/24"})
require.NoError(t, err)
proxyListener := requireProxyProtocolListener(t, wrapped)
assert.Equal(t, proxyProtocolReadHeaderTimeout, proxyListener.ReadHeaderTimeout)
policy, err := proxyListener.ConnPolicy(proxyproto.ConnPolicyOptions{
Upstream: &net.TCPAddr{IP: net.ParseIP("192.0.2.10"), Port: 12345},
})
require.NoError(t, err)
assert.Equal(t, proxyproto.REQUIRE, policy)
_, err = proxyListener.ConnPolicy(proxyproto.ConnPolicyOptions{
Upstream: &net.TCPAddr{IP: net.ParseIP("198.51.100.10"), Port: 12345},
})
require.ErrorIs(t, err, proxyproto.ErrInvalidUpstream)
policy, err = proxyListener.ConnPolicy(proxyproto.ConnPolicyOptions{
Upstream: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 12345},
})
require.NoError(t, err)
assert.Equal(t, proxyproto.SKIP, policy)
}
func TestProxyProtocolListenerAllowsLoopbackWithoutHeader(t *testing.T) {
listener, clientConn := newProxyProtocolTestConnection(t, "127.0.0.1")
wrapped, err := newProxyProtocolListener(listener, []string{"192.0.2.0/24"})
require.NoError(t, err)
serverConn, err := wrapped.Accept()
require.NoError(t, err)
writeErr := make(chan error, 1)
go func() {
_, err := clientConn.Write([]byte("healthcheck"))
writeErr <- err
}()
payload := make([]byte, len("healthcheck"))
_, err = io.ReadFull(serverConn, payload)
require.NoError(t, err)
require.NoError(t, <-writeErr)
assert.Equal(t, "healthcheck", string(payload))
assert.Equal(t, "127.0.0.1:12345", serverConn.RemoteAddr().String())
}
func TestProxyProtocolListenerAcceptsHeaderFromTrustedLoopback(t *testing.T) {
listener, clientConn := newProxyProtocolTestConnection(t, "::1")
wrapped, err := newProxyProtocolListener(listener, []string{"::1"})
require.NoError(t, err)
serverConn, err := wrapped.Accept()
require.NoError(t, err)
source := &net.TCPAddr{IP: net.ParseIP("2001:db8::15"), Port: 42300}
dest := &net.TCPAddr{IP: net.ParseIP("::1"), Port: 1411}
writeErr := make(chan error, 1)
go func() {
header := proxyproto.HeaderProxyFromAddrs(2, source, dest)
if _, err := header.WriteTo(clientConn); err != nil {
writeErr <- err
return
}
_, err := clientConn.Write([]byte("payload"))
writeErr <- err
}()
payload := make([]byte, len("payload"))
_, err = io.ReadFull(serverConn, payload)
require.NoError(t, err)
require.NoError(t, <-writeErr)
assert.Equal(t, "payload", string(payload))
assert.Equal(t, source.String(), serverConn.RemoteAddr().String())
}
func TestProxyProtocolListenerAllowsTrustedLoopbackWithoutHeader(t *testing.T) {
listener, clientConn := newProxyProtocolTestConnection(t, "::1")
wrapped, err := newProxyProtocolListener(listener, []string{"::1"})
require.NoError(t, err)
serverConn, err := wrapped.Accept()
require.NoError(t, err)
writeErr := make(chan error, 1)
go func() {
_, err := clientConn.Write([]byte("healthcheck"))
writeErr <- err
}()
payload := make([]byte, len("healthcheck"))
_, err = io.ReadFull(serverConn, payload)
require.NoError(t, err)
require.NoError(t, <-writeErr)
assert.Equal(t, "healthcheck", string(payload))
assert.Equal(t, "[::1]:12345", serverConn.RemoteAddr().String())
}
func TestProxyProtocolListenerDropsUntrustedPeerAndContinues(t *testing.T) {
untrustedServerConn, untrustedClientConn := newAddressedPipe("198.51.100.10")
trustedServerConn, trustedClientConn := newAddressedPipe("192.0.2.10")
listener := &queuedConnListener{
conns: []net.Conn{untrustedServerConn, trustedServerConn},
addr: trustedServerConn.LocalAddr(),
}
t.Cleanup(func() {
_ = listener.Close()
_ = untrustedServerConn.Close()
_ = trustedServerConn.Close()
_ = untrustedClientConn.Close()
_ = trustedClientConn.Close()
})
wrapped, err := newProxyProtocolListener(listener, []string{"192.0.2.0/24"})
require.NoError(t, err)
serverConn, err := wrapped.Accept()
require.NoError(t, err)
source := &net.TCPAddr{IP: net.ParseIP("203.0.113.15"), Port: 42300}
dest := &net.TCPAddr{IP: net.ParseIP("192.0.2.20"), Port: 443}
writeErr := make(chan error, 1)
go func() {
header := proxyproto.HeaderProxyFromAddrs(1, source, dest)
if _, err := header.WriteTo(trustedClientConn); err != nil {
writeErr <- err
return
}
_, err := trustedClientConn.Write([]byte("payload"))
writeErr <- err
}()
payload := make([]byte, len("payload"))
_, err = io.ReadFull(serverConn, payload)
require.NoError(t, err)
require.NoError(t, <-writeErr)
assert.Equal(t, source.String(), serverConn.RemoteAddr().String())
_, err = untrustedClientConn.Write([]byte("payload"))
require.Error(t, err)
}
func TestProxyProtocolListenerExtractsClientAddress(t *testing.T) {
testCases := []struct {
name string
version byte
source *net.TCPAddr
dest *net.TCPAddr
}{
{
name: "version 1 IPv4",
version: 1,
source: &net.TCPAddr{IP: net.ParseIP("203.0.113.15"), Port: 42300},
dest: &net.TCPAddr{IP: net.ParseIP("192.0.2.20"), Port: 443},
},
{
name: "version 2 IPv6",
version: 2,
source: &net.TCPAddr{IP: net.ParseIP("2001:db8::15"), Port: 42300},
dest: &net.TCPAddr{IP: net.ParseIP("2001:db8::20"), Port: 443},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
listener, clientConn := newProxyProtocolTestConnection(t, "192.0.2.10")
wrapped, err := newProxyProtocolListener(listener, []string{"192.0.2.0/24"})
require.NoError(t, err)
serverConn, err := wrapped.Accept()
require.NoError(t, err)
writeErr := make(chan error, 1)
go func() {
header := proxyproto.HeaderProxyFromAddrs(testCase.version, testCase.source, testCase.dest)
if _, err := header.WriteTo(clientConn); err != nil {
writeErr <- err
return
}
_, err := clientConn.Write([]byte("payload"))
writeErr <- err
}()
payload := make([]byte, len("payload"))
_, err = io.ReadFull(serverConn, payload)
require.NoError(t, err)
require.NoError(t, <-writeErr)
assert.Equal(t, "payload", string(payload))
assert.Equal(t, testCase.source.String(), serverConn.RemoteAddr().String())
assert.Equal(t, testCase.dest.String(), serverConn.LocalAddr().String())
})
}
}
func TestProxyProtocolListenerRejectsMissingHeader(t *testing.T) {
listener, clientConn := newProxyProtocolTestConnection(t, "192.0.2.10")
wrapped, err := newProxyProtocolListener(listener, []string{"192.0.2.0/24"})
require.NoError(t, err)
serverConn, err := wrapped.Accept()
require.NoError(t, err)
go func() {
_, _ = clientConn.Write([]byte("GET /healthz HTTP/1.1\r\n"))
}()
_, err = serverConn.Read(make([]byte, 1))
require.ErrorIs(t, err, proxyproto.ErrNoProxyProtocol)
}
func TestProxyProtocolListenerSetsGinClientIP(t *testing.T) {
listener, clientConn := newProxyProtocolTestConnection(t, "192.0.2.10")
wrapped, err := newProxyProtocolListener(listener, []string{"192.0.2.0/24"})
require.NoError(t, err)
engine := gin.New()
require.NoError(t, engine.SetTrustedProxies(nil))
clientIP := make(chan string, 1)
engine.GET("/client-ip", func(c *gin.Context) {
clientIP <- c.ClientIP()
c.Status(http.StatusNoContent)
})
server := &http.Server{
Handler: engine,
ReadHeaderTimeout: time.Second,
}
t.Cleanup(func() {
_ = server.Close()
})
go func() {
_ = server.Serve(wrapped)
}()
source := &net.TCPAddr{IP: net.ParseIP("203.0.113.15"), Port: 42300}
dest := &net.TCPAddr{IP: net.ParseIP("192.0.2.20"), Port: 443}
writeErr := make(chan error, 1)
go func() {
header := proxyproto.HeaderProxyFromAddrs(1, source, dest)
if _, err := header.WriteTo(clientConn); err != nil {
writeErr <- err
return
}
_, err := clientConn.Write([]byte("GET /client-ip HTTP/1.1\r\nHost: pocket-id.example\r\nConnection: close\r\n\r\n"))
writeErr <- err
}()
select {
case actualClientIP := <-clientIP:
assert.Equal(t, "203.0.113.15", actualClientIP)
case <-time.After(time.Second):
t.Fatal("timed out waiting for Gin to handle the proxied request")
}
require.NoError(t, <-writeErr)
}
func TestProxyProtocolHeaderIsReadBeforeTLS(t *testing.T) {
listener, clientConn := newProxyProtocolTestConnection(t, "192.0.2.10")
proxyListener, err := newProxyProtocolListener(listener, []string{"192.0.2.0/24"})
require.NoError(t, err)
serverTLSConfig, clientTLSConfig := newProxyProtocolTestTLSConfigs(t)
tlsListener := tls.NewListener(proxyListener, serverTLSConfig)
serverConn, err := tlsListener.Accept()
require.NoError(t, err)
source := &net.TCPAddr{IP: net.ParseIP("203.0.113.15"), Port: 42300}
dest := &net.TCPAddr{IP: net.ParseIP("192.0.2.20"), Port: 443}
ctx := t.Context()
clientErr := make(chan error, 1)
go func() {
header := proxyproto.HeaderProxyFromAddrs(2, source, dest)
if _, err := header.WriteTo(clientConn); err != nil {
clientErr <- err
return
}
tlsClient := tls.Client(clientConn, clientTLSConfig)
if err := tlsClient.HandshakeContext(ctx); err != nil {
clientErr <- err
return
}
_, err := tlsClient.Write([]byte("payload"))
clientErr <- err
}()
tlsServerConn, ok := serverConn.(*tls.Conn)
require.True(t, ok)
require.NoError(t, tlsServerConn.HandshakeContext(ctx))
payload := make([]byte, len("payload"))
_, err = io.ReadFull(serverConn, payload)
require.NoError(t, err)
require.NoError(t, <-clientErr)
assert.Equal(t, "payload", string(payload))
assert.Equal(t, source.String(), serverConn.RemoteAddr().String())
}
func requireProxyProtocolListener(t *testing.T, listener net.Listener) *proxyproto.Listener {
t.Helper()
proxyListener, ok := listener.(*proxyproto.Listener)
require.True(t, ok)
return proxyListener
}
func newProxyProtocolTestConnection(t *testing.T, upstreamIP string) (*singleConnListener, net.Conn) {
t.Helper()
serverConn, clientConn := newAddressedPipe(upstreamIP)
listener := &singleConnListener{
conn: serverConn,
addr: serverConn.LocalAddr(),
closed: make(chan struct{}),
}
t.Cleanup(func() {
_ = listener.Close()
_ = clientConn.Close()
})
return listener, clientConn
}
func newAddressedPipe(upstreamIP string) (net.Conn, net.Conn) {
serverConn, clientConn := net.Pipe()
return &addressedConn{
Conn: serverConn,
localAddr: &net.TCPAddr{IP: net.ParseIP("192.0.2.20"), Port: 1411},
remoteAddr: &net.TCPAddr{IP: net.ParseIP(upstreamIP), Port: 12345},
}, clientConn
}
func newProxyProtocolTestTLSConfigs(t *testing.T) (*tls.Config, *tls.Config) {
t.Helper()
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
now := time.Now()
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
DNSNames: []string{"localhost"},
NotBefore: now.Add(-time.Minute),
NotAfter: now.Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
certificateDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
require.NoError(t, err)
certificate, err := x509.ParseCertificate(certificateDER)
require.NoError(t, err)
roots := x509.NewCertPool()
roots.AddCert(certificate)
serverConfig := &tls.Config{
Certificates: []tls.Certificate{{
Certificate: [][]byte{certificateDER},
PrivateKey: privateKey,
}},
MinVersion: tls.VersionTLS13,
}
clientConfig := &tls.Config{
RootCAs: roots,
ServerName: "localhost",
MinVersion: tls.VersionTLS13,
}
return serverConfig, clientConfig
}
type singleConnListener struct {
conn net.Conn
addr net.Addr
accepted bool
closed chan struct{}
closeOnce sync.Once
}
func (l *singleConnListener) Accept() (net.Conn, error) {
if l.accepted {
<-l.closed
return nil, net.ErrClosed
}
l.accepted = true
return l.conn, nil
}
func (l *singleConnListener) Close() error {
var closeErr error
l.closeOnce.Do(func() {
close(l.closed)
closeErr = l.conn.Close()
})
return closeErr
}
func (l *singleConnListener) Addr() net.Addr {
return l.addr
}
type addressedConn struct {
net.Conn
localAddr net.Addr
remoteAddr net.Addr
}
func (c *addressedConn) LocalAddr() net.Addr {
return c.localAddr
}
func (c *addressedConn) RemoteAddr() net.Addr {
return c.remoteAddr
}
type queuedConnListener struct {
conns []net.Conn
addr net.Addr
}
func (l *queuedConnListener) Accept() (net.Conn, error) {
if len(l.conns) == 0 {
return nil, net.ErrClosed
}
conn := l.conns[0]
l.conns = l.conns[1:]
return conn, nil
}
func (l *queuedConnListener) Close() error {
for _, conn := range l.conns {
_ = conn.Close()
}
return nil
}
func (l *queuedConnListener) Addr() net.Addr {
return l.addr
}
@@ -222,7 +222,18 @@ func initServer(r *gin.Engine) (*serverConfig, error) {
}
addr := socket.addr
listener := socket.listener
// Wrap the listener with a proxy protocol listener if configured and not using a Unix socket
if len(common.EnvConfig.ProxyProtocol) > 0 && common.EnvConfig.UnixSocket == "" {
listener, err = newProxyProtocolListener(socket.listener, common.EnvConfig.ProxyProtocol)
if err != nil {
_ = socket.listener.Close()
return nil, err
}
}
server := newHTTPServer(r, protocols)
return &serverConfig{addr, certProvider, listener, server, tlsConfig}, nil
+27 -4
View File
@@ -2,6 +2,7 @@ package cmds
import (
"context"
"crypto/tls"
"fmt"
"log/slog"
"net"
@@ -64,13 +65,27 @@ func init() {
},
}
healthcheckCmd.Flags().StringVarP(&flags.Endpoint, "endpoint", "e", "http://localhost:"+common.EnvConfig.Port, "Endpoint for Pocket ID")
healthcheckCmd.Flags().StringVarP(&flags.Endpoint, "endpoint", "e", defaultEndpoint(), "Endpoint for Pocket ID")
healthcheckCmd.Flags().StringVar(&flags.UnixSocket, "unix-socket", "", "UNIX socket path for Pocket ID")
healthcheckCmd.Flags().BoolVarP(&flags.Verbose, "verbose", "v", false, "Enable verbose mode")
rootCmd.AddCommand(healthcheckCmd)
}
// The server only serves TLS when both a certificate and a key file are configured
func tlsEnabled() bool {
return common.EnvConfig.TLSCertFile != "" && common.EnvConfig.TLSKeyFile != ""
}
func defaultEndpoint() string {
scheme := "http"
if tlsEnabled() {
scheme = "https"
}
return scheme + "://localhost:" + common.EnvConfig.Port
}
func healthcheck(ctx context.Context, flags healthcheckFlags) (*healthcheckResult, error) {
url := strings.TrimRight(flags.Endpoint, "/") + "/healthz"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
@@ -78,17 +93,25 @@ func healthcheck(ctx context.Context, flags healthcheckFlags) (*healthcheckResul
return nil, fmt.Errorf("failed to create request object for %q: %w", url, err)
}
client := http.DefaultClient
transport := http.DefaultTransport.(*http.Transport).Clone()
if flags.UnixSocket != "" {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
dialer := net.Dialer{}
return dialer.DialContext(ctx, "unix", flags.UnixSocket)
}
client = &http.Client{Transport: transport}
}
// If TLS cert and key files are provided, there is a high chance that the server is using a self-signed certificate
if tlsEnabled() {
transport.TLSClientConfig = &tls.Config{
InsecureSkipVerify: true, //nolint:gosec // no sensitive data is transmitted in healthcheck
}
}
client := &http.Client{Transport: transport}
res, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to perform request to %q: %w", url, err)
+129
View File
@@ -2,15 +2,24 @@ package cmds
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"math/big"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
func TestHealthcheckTCPSuccess(t *testing.T) {
@@ -77,3 +86,123 @@ func TestHealthcheckUnixSocket(t *testing.T) {
require.Equal(t, http.StatusNoContent, result.StatusCode)
require.Equal(t, "http://localhost:1411/healthz", result.URL)
}
// The server also serves TLS over a UNIX socket, so the healthcheck must keep dialing
// the socket instead of falling back to the host in the endpoint
func TestHealthcheckUnixSocketWithTLS(t *testing.T) {
setTLSFiles(t)
socketPath := filepath.Join(shortTempDir(t), "s.sock")
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "unix", socketPath)
require.NoError(t, err)
server := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/healthz", r.URL.Path)
w.WriteHeader(http.StatusNoContent)
}),
ReadHeaderTimeout: time.Second,
TLSConfig: newSelfSignedTLSConfig(t),
}
errCh := make(chan error, 1)
go func() {
errCh <- server.ServeTLS(listener, "", "")
}()
// The connection is upgraded to HTTP/2, which keeps a graceful shutdown waiting for the idle client connection
t.Cleanup(func() {
require.NoError(t, server.Close())
require.ErrorIs(t, <-errCh, http.ErrServerClosed)
})
result, err := healthcheck(t.Context(), healthcheckFlags{
Endpoint: "https://localhost:1411",
UnixSocket: socketPath,
})
require.NoError(t, err)
require.Equal(t, http.StatusNoContent, result.StatusCode)
}
// The healthcheck must reach a server that serves TLS with a self-signed certificate
func TestHealthcheckTLSSelfSignedCertificate(t *testing.T) {
setTLSFiles(t)
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/healthz", r.URL.Path)
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
result, err := healthcheck(t.Context(), healthcheckFlags{
Endpoint: server.URL,
})
require.NoError(t, err)
require.Equal(t, http.StatusNoContent, result.StatusCode)
}
func TestDefaultEndpoint(t *testing.T) {
t.Run("uses http if no TLS certificate is configured", func(t *testing.T) {
assert.Equal(t, "http://localhost:"+common.EnvConfig.Port, defaultEndpoint())
})
t.Run("uses https if a TLS certificate is configured", func(t *testing.T) {
setTLSFiles(t)
assert.Equal(t, "https://localhost:"+common.EnvConfig.Port, defaultEndpoint())
})
}
// t.TempDir embeds the test name, which can exceed the maximum socket path length
func shortTempDir(t *testing.T) string {
t.Helper()
dir, err := os.MkdirTemp("", "pid")
require.NoError(t, err)
t.Cleanup(func() {
_ = os.RemoveAll(dir)
})
return dir
}
// Only the presence of the paths matters, the healthcheck never reads the files
func setTLSFiles(t *testing.T) {
t.Helper()
certFile, keyFile := common.EnvConfig.TLSCertFile, common.EnvConfig.TLSKeyFile
t.Cleanup(func() {
common.EnvConfig.TLSCertFile, common.EnvConfig.TLSKeyFile = certFile, keyFile
})
common.EnvConfig.TLSCertFile = "cert.pem"
common.EnvConfig.TLSKeyFile = "key.pem"
}
func newSelfSignedTLSConfig(t *testing.T) *tls.Config {
t.Helper()
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
now := time.Now()
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
DNSNames: []string{"localhost"},
NotBefore: now.Add(-time.Minute),
NotAfter: now.Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
certificateDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
require.NoError(t, err)
return &tls.Config{
Certificates: []tls.Certificate{{
Certificate: [][]byte{certificateDER},
PrivateKey: privateKey,
}},
MinVersion: tls.VersionTLS13,
}
}
+9
View File
@@ -46,6 +46,7 @@ type EnvConfigSchema struct {
DbProvider DbProvider
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
TrustProxy TrustProxyConfig `env:"TRUST_PROXY"`
ProxyProtocol TrustProxyConfig `env:"PROXY_PROTOCOL"`
TrustedPlatform string `env:"TRUSTED_PLATFORM"`
AuditLogRetentionDays int `env:"AUDIT_LOG_RETENTION_DAYS"`
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
@@ -158,6 +159,9 @@ func ValidateEnvConfig(config *EnvConfigSchema) error {
if config.SystemdSocket && config.UnixSocket != "" {
return errors.New("SYSTEMD_SOCKET and UNIX_SOCKET are mutually exclusive")
}
if len(config.ProxyProtocol) > 0 && config.UnixSocket != "" {
return errors.New("PROXY_PROTOCOL and UNIX_SOCKET are mutually exclusive")
}
if config.AuditLogRetentionDays <= 0 {
return errors.New("AUDIT_LOG_RETENTION_DAYS must be greater than 0")
@@ -414,6 +418,11 @@ func (config *TrustProxyConfig) UnmarshalText(text []byte) error {
proxies := strings.Split(value, ",")
for i, proxy := range proxies {
proxy = strings.TrimSpace(proxy)
if net.ParseIP(proxy) == nil {
if _, _, err := net.ParseCIDR(proxy); err != nil {
return fmt.Errorf("invalid proxy IP address or CIDR %q", proxy)
}
}
proxies[i] = proxy
}
@@ -118,6 +118,7 @@ func TestParseEnvConfig(t *testing.T) {
t.Setenv("METRICS_ENABLED", "true")
t.Setenv("TRACING_ENABLED", "false")
t.Setenv("TRUST_PROXY", "true")
t.Setenv("PROXY_PROTOCOL", "true")
t.Setenv("ANALYTICS_DISABLED", "false")
t.Setenv("ALLOW_INSECURE_CALLBACK_URLS", "false")
@@ -125,6 +126,7 @@ func TestParseEnvConfig(t *testing.T) {
require.NoError(t, err)
assert.True(t, EnvConfig.UiConfigDisabled)
assert.Equal(t, TrustProxyConfig{"0.0.0.0/0", "::/0"}, EnvConfig.TrustProxy)
assert.Equal(t, TrustProxyConfig{"0.0.0.0/0", "::/0"}, EnvConfig.ProxyProtocol)
assert.False(t, EnvConfig.AnalyticsDisabled)
assert.False(t, EnvConfig.AllowInsecureCallbackURLs)
})
@@ -147,6 +149,34 @@ func TestParseEnvConfig(t *testing.T) {
assert.Nil(t, EnvConfig.TrustProxy)
})
t.Run("should parse PROXY protocol trusted proxy IP addresses and CIDR ranges", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("PROXY_PROTOCOL", "10.0.0.0/8, 192.168.1.10, ::1/128")
err := parseAndValidateEnvConfig(t)
require.NoError(t, err)
assert.Equal(t, TrustProxyConfig{"10.0.0.0/8", "192.168.1.10", "::1/128"}, EnvConfig.ProxyProtocol)
})
t.Run("should reject an invalid PROXY protocol trusted proxy", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("PROXY_PROTOCOL", "not-an-ip")
err := parseAndValidateEnvConfig(t)
require.Error(t, err)
assert.ErrorContains(t, err, "invalid proxy IP address or CIDR")
})
t.Run("should reject PROXY protocol with a UNIX socket", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("PROXY_PROTOCOL", "true")
t.Setenv("UNIX_SOCKET", "/tmp/pocket-id.sock")
err := parseAndValidateEnvConfig(t)
require.Error(t, err)
assert.ErrorContains(t, err, "PROXY_PROTOCOL and UNIX_SOCKET are mutually exclusive")
})
t.Run("should allow insecure callback URLs by default", func(t *testing.T) {
assert.True(t, defaultConfig().AllowInsecureCallbackURLs)
})
+45 -2
View File
@@ -301,15 +301,26 @@ func (s *UserService) CreateUserInternal(ctx context.Context, input dto.UserCrea
return model.User{}, err
}
// Bump the UpdatedAt timestamp of the groups the new user was added to
// This is necessary for SCIM to work with the newly-created user, or groups may not be synced via SCIM
if len(userGroups) > 0 {
err = s.touchUserGroups(ctx, tx, groupIDs(userGroups))
if err != nil {
return model.User{}, err
}
}
// Apply default groups and claims for new non-LDAP users
if !isLdapSync {
if len(input.UserGroupIds) == 0 {
if err := s.applyDefaultGroups(ctx, &user, tx); err != nil {
err = s.applyDefaultGroups(ctx, &user, tx)
if err != nil {
return model.User{}, err
}
}
if err := s.applyDefaultCustomClaims(ctx, &user, tx); err != nil {
err = s.applyDefaultCustomClaims(ctx, &user, tx)
if err != nil {
return model.User{}, err
}
}
@@ -348,11 +359,43 @@ func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User,
if err != nil {
return fmt.Errorf("failed to associate default user groups: %w", err)
}
// Bump the groups' UpdatedAt so the SCIM sync picks up the new
// membership (see touchUserGroups for details).
touchIDs := make([]string, len(groups))
for i := range groups {
touchIDs[i] = groups[i].ID
}
if err := s.touchUserGroups(ctx, tx, touchIDs); err != nil {
return fmt.Errorf("failed to update default user groups timestamp: %w", err)
}
}
}
return nil
}
// touchUserGroups updates the UpdatedAt timestamp of the given user groups.
//
// Group membership is stored in the user_groups_users join table, so adding or
// removing a member does not modify the group row itself. The SCIM sync only
// re-pushes a group to the provider when its UpdatedAt is not older than the
// remote resource's last-modified time, so any code path that changes a group's
// membership must bump this timestamp explicitly. Otherwise the membership
// change is never synced to the SCIM provider.
func (s *UserService) touchUserGroups(ctx context.Context, tx *gorm.DB, ids []string) error {
if len(ids) == 0 {
return nil
}
now := datatype.DateTime(time.Now())
return tx.
WithContext(ctx).
Model(&model.UserGroup{}).
Where("id IN ?", ids).
Update("updated_at", now).
Error
}
func (s *UserService) applyDefaultCustomClaims(ctx context.Context, user *model.User, tx *gorm.DB) error {
config := s.appConfigService.GetDbConfig()
@@ -0,0 +1,104 @@
package service
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/storage"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
func newTestUserService(t *testing.T, appConfig *AppConfigService) (*UserService, *UserGroupService) {
t.Helper()
db := testutils.NewDatabaseForTest(t)
fileStorage, err := storage.NewDatabaseStorage(db)
require.NoError(t, err)
userService := NewUserService(
db,
nil,
nil,
nil,
appConfig,
NewCustomClaimService(db),
NewAppImagesService(map[string]string{}, fileStorage),
nil,
fileStorage,
)
groupService := NewUserGroupService(db, appConfig, nil)
return userService, groupService
}
func TestCreateUserBumpsGroupUpdatedAt(t *testing.T) {
appConfig := NewTestAppConfigService(&model.AppConfig{
RequireUserEmail: model.AppConfigVariable{Value: "false"},
})
userService, groupService := newTestUserService(t, appConfig)
group, err := groupService.Create(t.Context(), dto.UserGroupCreateDto{
Name: "members",
FriendlyName: "Members",
})
require.NoError(t, err)
require.Nil(t, group.UpdatedAt, "a freshly created group has no UpdatedAt yet")
// Create a user that is a member of the group
// This mirrors signing up via an invite link that adds the user to a group
email := "member@example.com"
_, err = userService.CreateUser(t.Context(), dto.UserCreateDto{
Username: "member",
Email: &email,
FirstName: "Group",
LastName: "Member",
UserGroupIds: []string{group.ID},
})
require.NoError(t, err)
// The group's UpdatedAt must now be set
updated, err := groupService.Get(t.Context(), group.ID)
require.NoError(t, err)
require.NotNil(t, updated.UpdatedAt, "creating a group member must bump the group's UpdatedAt")
require.False(t, updated.LastModified().Before(updated.CreatedAt.ToTime()), "group LastModified must not predate its CreatedAt after a membership change")
require.Len(t, updated.Users, 1, "the user should be a member of the group")
}
func TestCreateUserBumpsDefaultGroupUpdatedAt(t *testing.T) {
appConfig := NewTestAppConfigService(&model.AppConfig{
RequireUserEmail: model.AppConfigVariable{Value: "false"},
})
userService, groupService := newTestUserService(t, appConfig)
group, err := groupService.Create(t.Context(), dto.UserGroupCreateDto{
Name: "default",
FriendlyName: "Default",
})
require.NoError(t, err)
require.Nil(t, group.UpdatedAt)
// Configure the group as a default signup group
defaultGroups, err := json.Marshal([]string{group.ID})
require.NoError(t, err)
appConfig.dbConfig.Load().SignupDefaultUserGroupIDs.Value = string(defaultGroups)
// Create a user without explicit group IDs, so the default groups apply
email := "default@example.com"
_, err = userService.CreateUser(t.Context(), dto.UserCreateDto{
Username: "defaultmember",
Email: &email,
FirstName: "Default",
LastName: "Member",
})
require.NoError(t, err)
updated, err := groupService.Get(t.Context(), group.ID)
require.NoError(t, err)
require.NotNil(t, updated.UpdatedAt, "adding a default group member must bump the group's UpdatedAt")
require.Len(t, updated.Users, 1)
}