Skip to content

Commit

Permalink
Fly-Src header authorizer (#24)
Browse files Browse the repository at this point in the history
  • Loading branch information
btoews authored Feb 4, 2025
1 parent a17a2e0 commit 67a0939
Show file tree
Hide file tree
Showing 5 changed files with 377 additions and 5 deletions.
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ COPY ./macaroon ./macaroon
COPY ./cmd/tokenizer ./cmd/tokenizer
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg \
go build -ldflags "-X 'main.Version=$(cat VERSION)' -X 'main.FilteredHeaders=Fly-Client-Ip,Fly-Forwarded-Port,Fly-Forwarded-Proto,Fly-Forwarded-Ssl,Fly-Region,Fly-Request-Id,Fly-Traceparent,Fly-Tracestate'" -buildvcs=false -o ./bin/tokenizer ./cmd/tokenizer
go build -ldflags "-X 'main.Version=$(cat VERSION)' -X 'main.FilteredHeaders=Fly-Client-Ip,Fly-Forwarded-Port,Fly-Forwarded-Proto,Fly-Forwarded-Ssl,Fly-Region,Fly-Request-Id,Fly-Traceparent,Fly-Tracestate,Fly-Src,Fly-Src-Signature'" -buildvcs=false -o ./bin/tokenizer ./cmd/tokenizer

FROM alpine:latest AS runner
WORKDIR /root
Expand Down
209 changes: 205 additions & 4 deletions authorizer.go
Original file line number Diff line number Diff line change
@@ -1,24 +1,41 @@
package tokenizer

import (
"crypto/ed25519"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"

"github.com/sirupsen/logrus"
"github.com/superfly/macaroon"
"github.com/superfly/macaroon/bundle"
"github.com/superfly/macaroon/flyio"
"github.com/superfly/macaroon/flyio/machinesapi"
tkmac "github.com/superfly/tokenizer/macaroon"
"golang.org/x/exp/slices"
)

const headerProxyAuthorization = "Proxy-Authorization"
const (
headerProxyAuthorization = "Proxy-Authorization"
headerFlySrc = "Fly-Src"
headerFlySrcSignature = "Fly-Src-Signature"
flySrcSignatureKeyPath = "/.fly/fly-src.pub"

maxFlySrcAge = 30 * time.Second
)

var (
flySrcSignatureKey = readFlySrcKey(flySrcSignatureKeyPath)
)

type AuthConfig interface {
AuthRequest(req *http.Request) error
Expand All @@ -33,7 +50,7 @@ func NewBearerAuthConfig(token string) *BearerAuthConfig {
return &BearerAuthConfig{digest[:]}
}

var _ AuthConfig = new(BearerAuthConfig)
var _ AuthConfig = (*BearerAuthConfig)(nil)

func (c *BearerAuthConfig) AuthRequest(req *http.Request) error {
for _, tok := range proxyAuthorizationTokens(req) {
Expand All @@ -54,7 +71,7 @@ func NewMacaroonAuthConfig(key []byte) *MacaroonAuthConfig {
return &MacaroonAuthConfig{Key: key}
}

var _ AuthConfig = new(MacaroonAuthConfig)
var _ AuthConfig = (*MacaroonAuthConfig)(nil)

func (c *MacaroonAuthConfig) AuthRequest(req *http.Request) error {
var (
Expand Down Expand Up @@ -112,7 +129,7 @@ func NewFlyioMacaroonAuthConfig(access *flyio.Access) *FlyioMacaroonAuthConfig {
return &FlyioMacaroonAuthConfig{Access: *access}
}

var _ AuthConfig = new(FlyioMacaroonAuthConfig)
var _ AuthConfig = (*FlyioMacaroonAuthConfig)(nil)

func (c *FlyioMacaroonAuthConfig) AuthRequest(req *http.Request) error {
var ctx = req.Context()
Expand Down Expand Up @@ -140,6 +157,190 @@ func (c *FlyioMacaroonAuthConfig) AuthRequest(req *http.Request) error {
return fmt.Errorf("%w: bad or missing proxy auth", ErrNotAuthorized)
}

// FlySrcAuthConfig allows permitting access to a secret based on the Fly-Src
// header added to Flycast requests between Fly.io machines/apps/orgs.
// https://community.fly.io/t/fly-src-authenticating-http-requests-between-fly-apps/20566
type FlySrcAuthConfig struct {
// AllowedOrgs is a list of Fly.io organization slugs that are allowed to
// use the secret. An empty/missing value means that the `org` portion of
// the Fly-Src header is not checked.
AllowedOrgs []string `json:"allowed_orgs"`

// AllowedApps is a list of Fly.io application slugs that are allowed to use
// the secret. An empty/missing value means that the `app` portion of the
// Fly-Src header is not checked.
AllowedApps []string `json:"allowed_apps"`

// AllowedInstances is a list of Fly.io instance IDs that are allowed to use
// the secret. An empty/missing value means that the `instance` portion of
// the Fly-Src header is not checked.
AllowedInstances []string `json:"allowed_instances"`
}

type FlySrcOpt func(*FlySrcAuthConfig)

// AllowlistFlySrcOrgs sets the list of allowed Fly.io organization slugs.
func AllowlistFlySrcOrgs(orgs ...string) FlySrcOpt {
return func(c *FlySrcAuthConfig) {
c.AllowedOrgs = append(c.AllowedOrgs, orgs...)
}
}

// AllowlistFlySrcApps sets the list of allowed Fly App names.
func AllowlistFlySrcApps(apps ...string) FlySrcOpt {
return func(c *FlySrcAuthConfig) {
c.AllowedApps = append(c.AllowedApps, apps...)
}
}

// AllowlistFlySrcInstances sets the list of allowed Fly.io instances.
func AllowlistFlySrcInstances(instances ...string) FlySrcOpt {
return func(c *FlySrcAuthConfig) {
c.AllowedInstances = append(c.AllowedInstances, instances...)
}
}

// NewFlySrcAuthConfig creates a new FlySrcAuthConfig with the given options.
func NewFlySrcAuthConfig(opts ...FlySrcOpt) *FlySrcAuthConfig {
c := new(FlySrcAuthConfig)
for _, opt := range opts {
opt(c)
}

return c
}

var _ AuthConfig = (*FlySrcAuthConfig)(nil)

func (c *FlySrcAuthConfig) AuthRequest(req *http.Request) error {
fs, err := flySrcFromRequest(req)
if err != nil {
return fmt.Errorf("%w: %w", ErrNotAuthorized, err)
}

if len(c.AllowedOrgs) > 0 && !slices.Contains(c.AllowedOrgs, fs.Org) {
return fmt.Errorf("%w: org %s not allowed", ErrNotAuthorized, fs.Org)
}

if len(c.AllowedApps) > 0 && !slices.Contains(c.AllowedApps, fs.App) {
return fmt.Errorf("%w: app %s not allowed", ErrNotAuthorized, fs.App)
}

if len(c.AllowedInstances) > 0 && !slices.Contains(c.AllowedInstances, fs.Instance) {
return fmt.Errorf("%w: instance %s not allowed", ErrNotAuthorized, fs.Instance)
}

return nil
}

type flySrc struct {
Org string
App string
Instance string
Timestamp time.Time
}

func flySrcFromRequest(req *http.Request) (*flySrc, error) {
srcHdr := req.Header.Get(headerFlySrc)
if srcHdr == "" {
return nil, errors.New("missing Fly-Src header")
}

sigHdr := req.Header.Get(headerFlySrcSignature)
if sigHdr == "" {
return nil, errors.New("missing Fly-Src signature")
}

return verifyAndParseFlySrc(srcHdr, sigHdr, flySrcSignatureKey)
}

func verifyAndParseFlySrc(srcHdr, sigHdr string, key ed25519.PublicKey) (*flySrc, error) {
sig, err := base64.StdEncoding.DecodeString(sigHdr)
if err != nil {
return nil, fmt.Errorf("bad Fly-Src signature: %w", err)
}

if !ed25519.Verify(key, []byte(srcHdr), sig) {
return nil, errors.New("bad Fly-Src signature")
}

fs, err := parseFlySrc(srcHdr)
if err != nil {
return nil, fmt.Errorf("bad Fly-Src header: %w", err)
}

if fs.age() > maxFlySrcAge {
return nil, fmt.Errorf("expired Fly-Src header")
}

return fs, nil
}

func parseFlySrc(hdr string) (*flySrc, error) {
var ret flySrc

parts := strings.Split(hdr, ";")
if n := len(parts); n != 4 {
return nil, fmt.Errorf("malformed Fly-Src header (%d parts)", n)
}

for _, part := range parts {
k, v, ok := strings.Cut(part, "=")
if !ok {
return nil, fmt.Errorf("malformed Fly-Src header (missing =)")
}

switch k {
case "org":
ret.Org = v
case "app":
ret.App = v
case "instance":
ret.Instance = v
case "ts":
tsi, err := strconv.Atoi(v)
if err != nil {
return nil, fmt.Errorf("malformed Fly-Src timestamp: %w", err)
}

ret.Timestamp = time.Unix(int64(tsi), 0)
default:
return nil, fmt.Errorf("malformed Fly-Src header (unknown key: %q)", k)
}
}

if ret.Org == "" || ret.App == "" || ret.Instance == "" || ret.Timestamp.IsZero() {
return nil, fmt.Errorf("malformed Fly-Src header (missing parts)")
}

return &ret, nil
}

func (fs *flySrc) age() time.Duration {
return time.Since(fs.Timestamp)
}

func readFlySrcKey(path string) ed25519.PublicKey {
hk, err := os.ReadFile(path)
if err != nil {
logrus.WithError(err).Warn("failed to read Fly-Src public key")
return nil
}

if size := len(hk); hex.DecodedLen(size) != ed25519.PublicKeySize {
logrus.WithField("size", size).Warn("bad Fly-Src public key size")
return nil
}

key := make(ed25519.PublicKey, ed25519.PublicKeySize)
if _, err := hex.Decode(key, hk); err != nil {
logrus.WithError(err).Warn("bad Fly-Src public key")
return nil
}

return key
}

func proxyAuthorizationTokens(req *http.Request) (ret []string) {
hdrLoop:
for _, hdr := range req.Header.Values(headerProxyAuthorization) {
Expand Down
95 changes: 95 additions & 0 deletions authorizer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package tokenizer

import (
"crypto/ed25519"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"time"

"github.com/alecthomas/assert/v2"
)

func TestVerifyAndParseFlySrc(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(nil)
assert.NoError(t, err)

// good
fs := &flySrc{"foo", "bar", "baz", time.Now().Truncate(time.Second)}
fs2, err := verifyAndParseFlySrc(fs.String(), fs.sign(priv), pub)
assert.NoError(t, err)
assert.Equal(t, fs, fs2)

// expired
fs = &flySrc{"foo", "bar", "baz", time.Now().Add(-time.Hour).Truncate(time.Second)}
_, err = verifyAndParseFlySrc(fs.String(), fs.sign(priv), pub)
assert.Error(t, err)

// bad signature
fs = &flySrc{"foo", "bar", "baz", time.Now().Truncate(time.Second)}
sig := (&flySrc{"other", "bar", "baz", time.Now().Truncate(time.Second)}).sign(priv)
_, err = verifyAndParseFlySrc(fs.String(), sig, pub)
assert.Error(t, err)

// missing fields
fs = &flySrc{"", "bar", "baz", time.Now().Truncate(time.Second)}
_, err = verifyAndParseFlySrc(fs.String(), fs.sign(priv), pub)
assert.Error(t, err)

fs = &flySrc{"foo", "", "baz", time.Now().Truncate(time.Second)}
_, err = verifyAndParseFlySrc(fs.String(), fs.sign(priv), pub)
assert.Error(t, err)

fs = &flySrc{"foo", "bar", "", time.Now().Truncate(time.Second)}
_, err = verifyAndParseFlySrc(fs.String(), fs.sign(priv), pub)
assert.Error(t, err)

fs = &flySrc{"foo", "bar", "baz", time.Time{}}
_, err = verifyAndParseFlySrc(fs.String(), fs.sign(priv), pub)
assert.Error(t, err)

// totally bogus
_, err = verifyAndParseFlySrc("hello world!", sig, pub)
assert.Error(t, err)
}

func TestReadFlySrcKey(t *testing.T) {
var (
path = filepath.Join(t.TempDir(), "k.pub")
keyHex = "93e9adb1615a6ce6238a13c264e7c8ba8f8b7a53717e86bb34fce3b80d45f1e5"
key = []byte{147, 233, 173, 177, 97, 90, 108, 230, 35, 138, 19, 194, 100, 231, 200, 186, 143, 139, 122, 83, 113, 126, 134, 187, 52, 252, 227, 184, 13, 69, 241, 229}
)

assert.NoError(t, os.WriteFile(path, []byte(keyHex), 0644))
assert.Equal(t, key, readFlySrcKey(path))
}

func (fs *flySrc) String() string {
return fmt.Sprintf("instance=%s;app=%s;org=%s;ts=%d", fs.Instance, fs.App, fs.Org, fs.Timestamp.Unix())
}

func (fs *flySrc) sign(key ed25519.PrivateKey) string {
return base64.StdEncoding.EncodeToString(ed25519.Sign(key, []byte(fs.String())))
}

var (
_setupTestFlySrcKeyOnce sync.Once
_flySrcSignaturePrivateKey ed25519.PrivateKey
)

func flySrcSignaturePrivateKey(t *testing.T) ed25519.PrivateKey {
t.Helper()

var err error

_setupTestFlySrcKeyOnce.Do(func() {
flySrcSignatureKey, _flySrcSignaturePrivateKey, err = ed25519.GenerateKey(nil)
})

assert.NoError(t, err)
assert.NotZero(t, _flySrcSignaturePrivateKey)
return _flySrcSignaturePrivateKey
}
7 changes: 7 additions & 0 deletions secret.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ type wireSecret struct {
*BearerAuthConfig `json:"bearer_auth,omitempty"`
*MacaroonAuthConfig `json:"macaroon_auth,omitempty"`
*FlyioMacaroonAuthConfig `json:"flyio_macaroon_auth,omitempty"`
*FlySrcAuthConfig `json:"fly_src_auth,omitempty"`
AllowHosts []string `json:"allowed_hosts,omitempty"`
AllowHostPattern string `json:"allowed_host_pattern,omitempty"`
}
Expand All @@ -71,6 +72,8 @@ func (s *Secret) MarshalJSON() ([]byte, error) {
ws.MacaroonAuthConfig = a
case *FlyioMacaroonAuthConfig:
ws.FlyioMacaroonAuthConfig = a
case *FlySrcAuthConfig:
ws.FlySrcAuthConfig = a
default:
return nil, errors.New("bad auth config")
}
Expand Down Expand Up @@ -145,6 +148,10 @@ func (s *Secret) UnmarshalJSON(b []byte) error {
na += 1
s.AuthConfig = ws.FlyioMacaroonAuthConfig
}
if ws.FlySrcAuthConfig != nil {
na += 1
s.AuthConfig = ws.FlySrcAuthConfig
}
if na != 1 {
return errors.New("bad auth config")
}
Expand Down
Loading

0 comments on commit 67a0939

Please sign in to comment.