197 lines
6.0 KiB
Go
197 lines
6.0 KiB
Go
package authz
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/netip"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/lixenwraith/logwisp/internal/config"
|
|
"github.com/lixenwraith/logwisp/internal/netacl"
|
|
|
|
"github.com/lixenwraith/auth"
|
|
)
|
|
|
|
// SessionCookie carries a browser's token in proxy mode
|
|
const SessionCookie = "logwisp_session"
|
|
|
|
const maxRevoked = 65536 // logged-out tokens remembered until they expire
|
|
|
|
// proxyMode is an http sink behind TLS-terminating reverse proxies: only they
|
|
// may connect, X-Forwarded-For names the client, logins are unbound (logwisp
|
|
// holds no certificate the browser sees) and sessions may be cookies.
|
|
type proxyMode struct {
|
|
trusted []netip.Prefix
|
|
plaintext bool // no tls on the hop from the proxies
|
|
mu sync.Mutex
|
|
revoked map[[32]byte]time.Time
|
|
}
|
|
|
|
func parseProxies(entries []string) (*proxyMode, error) {
|
|
m := &proxyMode{}
|
|
for _, e := range entries {
|
|
prefix, err := config.ParsePrefix(e, "tcp")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("auth: trusted_proxies entry %q: %w", e, err)
|
|
}
|
|
m.trusted = append(m.trusted, prefix.Masked())
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *proxyMode) trusts(addr netip.Addr) bool {
|
|
return slices.ContainsFunc(m.trusted, func(p netip.Prefix) bool { return p.Contains(addr) })
|
|
}
|
|
|
|
// exposedHop reports a plaintext hop from a proxy that may be on another host
|
|
func (m *proxyMode) exposedHop() bool {
|
|
return m.plaintext && slices.ContainsFunc(m.trusted, func(p netip.Prefix) bool { return !p.Addr().IsLoopback() })
|
|
}
|
|
|
|
// client is the rightmost X-Forwarded-For hop that is not a trusted proxy:
|
|
// hops to its left come from the client and prove nothing. When every hop is
|
|
// trusted, the client sits inside a proxy range and the leftmost hop is it.
|
|
func (m *proxyMode) client(peer string, h http.Header) (netip.Addr, error) {
|
|
if addr, ok := netacl.ParseHost(peer); !ok || !m.trusts(addr) {
|
|
return netip.Addr{}, fmt.Errorf("%s is not a trusted proxy", peer)
|
|
}
|
|
protos := headerList(h, "X-Forwarded-Proto")
|
|
if len(protos) == 0 || slices.ContainsFunc(protos, func(v string) bool { return !strings.EqualFold(v, "https") }) {
|
|
return netip.Addr{}, fmt.Errorf("proxy forwarded X-Forwarded-Proto %q; sessions need https", strings.Join(protos, ","))
|
|
}
|
|
hops := headerList(h, "X-Forwarded-For")
|
|
if len(hops) == 0 {
|
|
return netip.Addr{}, errors.New("proxy sent no client address in X-Forwarded-For")
|
|
}
|
|
for i := len(hops) - 1; ; i-- {
|
|
addr, ok := netacl.ParseHost(hops[i])
|
|
if !ok {
|
|
return netip.Addr{}, fmt.Errorf("malformed X-Forwarded-For hop %q", hops[i])
|
|
}
|
|
if i == 0 || !m.trusts(addr) {
|
|
return addr, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func headerList(h http.Header, key string) []string {
|
|
var out []string
|
|
for _, v := range h.Values(key) {
|
|
for e := range strings.SplitSeq(v, ",") {
|
|
if e = strings.TrimSpace(e); e != "" {
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (m *proxyMode) revoke(token string, until time.Time) bool {
|
|
key := sha256.Sum256([]byte(token))
|
|
now := time.Now()
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.revoked == nil {
|
|
m.revoked = make(map[[32]byte]time.Time)
|
|
}
|
|
if len(m.revoked) >= maxRevoked {
|
|
for k, expiry := range m.revoked {
|
|
if now.After(expiry) {
|
|
delete(m.revoked, k)
|
|
}
|
|
}
|
|
if len(m.revoked) >= maxRevoked {
|
|
return false
|
|
}
|
|
}
|
|
m.revoked[key] = until
|
|
return true
|
|
}
|
|
|
|
func (m *proxyMode) isRevoked(token string) bool {
|
|
key := sha256.Sum256([]byte(token))
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
_, ok := m.revoked[key]
|
|
return ok
|
|
}
|
|
|
|
// BehindProxy reports proxy mode (auth.trusted_proxies on an http sink)
|
|
func (p *Policy) BehindProxy() bool {
|
|
return p != nil && p.listener != nil && p.listener.proxy != nil
|
|
}
|
|
|
|
// ClientAddr is the address throttling, sessions and logs name: the socket
|
|
// peer, or in proxy mode the forwarded client. A proxy-mode request from an
|
|
// untrusted peer, one the proxy did not receive over https, or one for a
|
|
// client the acl rules refuse, is refused.
|
|
func (p *Policy) ClientAddr(r *http.Request) (string, error) {
|
|
peer := remoteIP(r.RemoteAddr)
|
|
if !p.BehindProxy() {
|
|
return peer, nil
|
|
}
|
|
addr, err := p.listener.proxy.client(peer, r.Header)
|
|
if err != nil {
|
|
p.rejected.Add(1)
|
|
return "", fmt.Errorf("%w: %w", ErrRefused, err)
|
|
}
|
|
if err := p.acl.Forwarded(addr, r.RemoteAddr); err != nil {
|
|
return "", fmt.Errorf("%w: %w", ErrRefused, err)
|
|
}
|
|
return addr.String(), nil
|
|
}
|
|
|
|
// presentedToken is the bearer token or, in proxy mode, the session cookie.
|
|
// There another scheme, such as the site's own Basic auth, leaves the cookie
|
|
// in charge.
|
|
func (p *Policy) presentedToken(r *http.Request) (string, error) {
|
|
h := r.Header.Get("Authorization")
|
|
if scheme, _, _ := strings.Cut(h, " "); p.BehindProxy() && !strings.EqualFold(scheme, "Bearer") {
|
|
h = ""
|
|
}
|
|
if h != "" {
|
|
token, err := auth.ParseBearerToken(h)
|
|
if err != nil {
|
|
return "", errors.New("malformed bearer token")
|
|
}
|
|
return token, nil
|
|
}
|
|
if p.BehindProxy() {
|
|
if c, err := r.Cookie(SessionCookie); err == nil && c.Value != "" {
|
|
return c.Value, nil
|
|
}
|
|
}
|
|
return "", errors.New("no bearer token")
|
|
}
|
|
|
|
// sessionCookie sets no Path: it defaults to the directory of /auth, which is
|
|
// the mount point behind any proxy prefix, so stream and status receive it.
|
|
func sessionCookie(token string, maxAge int) *http.Cookie {
|
|
return &http.Cookie{Name: SessionCookie, Value: token, MaxAge: maxAge,
|
|
HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode}
|
|
}
|
|
|
|
// logout clears the cookie and revokes a valid presented token until it
|
|
// expires. It answers on /auth itself: a clearing cookie set from another path
|
|
// would default to another Path and miss the login cookie.
|
|
func (p *Policy) logout(w http.ResponseWriter, r *http.Request) error {
|
|
l := p.listener
|
|
http.SetCookie(w, sessionCookie("", -1))
|
|
token, err := p.presentedToken(r)
|
|
if err == nil {
|
|
_, _, err = l.tokens.ValidateToken(token)
|
|
}
|
|
if err == nil && !l.proxy.revoke(token, time.Now().Add(l.lifetime)) {
|
|
l.busy.Add(1)
|
|
writeJSON(w, http.StatusServiceUnavailable, authStep{Error: "busy"})
|
|
return errors.New("auth: logout revocation list is full; the token stays valid until it expires")
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return nil
|
|
}
|