Skip to content

Commit e746ff0

Browse files
committed
httputil: rework request signing and request restriction
Signed-off-by: Hank Donnay <[email protected]>
1 parent 9f6828c commit e746ff0

File tree

5 files changed

+188
-78
lines changed

5 files changed

+188
-78
lines changed

Documentation/reference/config.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,9 @@ A key file for the TLS certificate. Encryption is not supported on the key.
159159
Indexer provides Clair Indexer node configuration.
160160

161161
#### `$.indexer.airgap`
162-
Boolean.
163-
164-
Disables scanners that have signaled they expect to talk to the Internet.
162+
Disables HTTP access to the Internet for indexers and fetchers.
163+
Private IPv4 and IPv6 addresses are allowed.
164+
Database connections are unaffected.
165165

166166
#### `$.indexer.connstring`
167167
A Postgres connection string.

config/indexer.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,16 @@ type Indexer struct {
3535
//
3636
// Whether Indexer nodes handle migrations to their database.
3737
Migrations bool `yaml:"migrations,omitempty" json:"migrations,omitempty"`
38-
// Airgap disables scanners that have signaled they expect to talk to the
39-
// Internet.
38+
// Airgap disables HTTP access to the Internet. This affects both indexers and
39+
// the layer fetcher. Database connections are unaffected.
40+
//
41+
// "Airgap" is a bit of a misnomer, as [RFC 4193] and [RFC 1918] addresses
42+
// are always allowed. This means that setting this flag and also
43+
// configuring a proxy on a private network does not prevent contact with
44+
// the Internet.
45+
//
46+
// [RFC 1918]: https://datatracker.ietf.org/doc/html/rfc1918
47+
// [RFC 4193]: https://datatracker.ietf.org/doc/html/rfc4193
4048
Airgap bool `yaml:"airgap,omitempty" json:"airgap,omitempty"`
4149
}
4250

internal/httputil/client.go

Lines changed: 60 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,97 +1,85 @@
11
package httputil
22

33
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"net"
48
"net/http"
59
"net/http/cookiejar"
6-
"time"
10+
"os"
11+
"path/filepath"
12+
"strings"
13+
"syscall"
714

8-
"github.com/quay/clair/config"
15+
"github.com/quay/clair/v4/cmd"
916
"golang.org/x/net/publicsuffix"
10-
"gopkg.in/square/go-jose.v2"
11-
"gopkg.in/square/go-jose.v2/jwt"
1217
)
1318

14-
// Client returns an http.Client configured according to the supplied
15-
// configuration.
19+
// NewClient constructs an [http.Client] that disallows access to public
20+
// networks, controlled by the localOnly flag.
1621
//
17-
// If nil is passed for a claim, the returned client does no signing.
18-
//
19-
// It returns an *http.Client and a boolean indicating whether the client is
20-
// configured for authentication, or an error that occurred during construction.
21-
func Client(next http.RoundTripper, cl *jwt.Claims, cfg *config.Config) (c *http.Client, authed bool, err error) {
22-
if next == nil {
23-
next = http.DefaultTransport.(*http.Transport).Clone()
22+
// If disallowed, the reported error will be a [*net.AddrError] with the "Err"
23+
// value of "disallowed by policy".
24+
func NewClient(ctx context.Context, localOnly bool) (*http.Client, error) {
25+
tr := http.DefaultTransport.(*http.Transport).Clone()
26+
dialer := &net.Dialer{}
27+
// Set a control function if we're restricting subnets.
28+
if localOnly {
29+
dialer.Control = ctlLocalOnly
2430
}
25-
authed = false
31+
tr.DialContext = dialer.DialContext
32+
2633
jar, err := cookiejar.New(&cookiejar.Options{
2734
PublicSuffixList: publicsuffix.List,
2835
})
2936
if err != nil {
30-
return nil, false, err
31-
}
32-
c = &http.Client{
33-
Jar: jar,
37+
return nil, err
3438
}
39+
return &http.Client{
40+
Transport: tr,
41+
Jar: jar,
42+
}, nil
43+
}
3544

36-
sk := jose.SigningKey{Algorithm: jose.HS256}
37-
// Keep this organized from "best" to "worst". That way, we can add methods
38-
// and keep everything working with some careful cluster rolling.
39-
switch {
40-
case cl == nil: // Skip signing
41-
case cfg.Auth.Keyserver != nil:
42-
sk.Key = []byte(cfg.Auth.Keyserver.Intraservice)
43-
case cfg.Auth.PSK != nil:
44-
sk.Key = []byte(cfg.Auth.PSK.Key)
45-
default:
46-
}
47-
rt := &transport{
48-
next: next,
45+
func ctlLocalOnly(network, address string, _ syscall.RawConn) error {
46+
// Future-proof for QUIC by allowing UDP here.
47+
if !strings.HasPrefix(network, "tcp") && !strings.HasPrefix(network, "udp") {
48+
return &net.AddrError{
49+
Addr: network + "!" + address,
50+
Err: "disallowed by policy",
51+
}
4952
}
50-
// If we have a claim, make a copy into the transport.
51-
if cl != nil {
52-
rt.base = *cl
53+
addr := net.ParseIP(address)
54+
if addr == nil {
55+
return &net.AddrError{
56+
Addr: network + "!" + address,
57+
Err: "martian address",
58+
}
5359
}
54-
c.Transport = rt
55-
56-
// Both of the JWT-based methods set the signing key.
57-
if sk.Key != nil {
58-
signer, err := jose.NewSigner(sk, nil)
59-
if err != nil {
60-
return nil, false, err
60+
if !addr.IsPrivate() {
61+
return &net.AddrError{
62+
Addr: network + "!" + address,
63+
Err: "disallowed by policy",
6164
}
62-
rt.Signer = signer
63-
authed = true
6465
}
65-
return c, authed, nil
66+
return nil
6667
}
6768

68-
var _ http.RoundTripper = (*transport)(nil)
69-
70-
// Transport does request modification common to all requests.
71-
type transport struct {
72-
jose.Signer
73-
next http.RoundTripper
74-
base jwt.Claims
75-
}
76-
77-
func (cs *transport) RoundTrip(r *http.Request) (*http.Response, error) {
78-
const (
79-
userAgent = `clair/v4`
80-
)
81-
r.Header.Set("user-agent", userAgent)
82-
if cs.Signer != nil {
83-
// TODO(hank) Make this mint longer-lived tokens and re-use them, only
84-
// refreshing when needed. Like a resettable sync.Once.
85-
now := time.Now()
86-
cl := cs.base
87-
cl.IssuedAt = jwt.NewNumericDate(now)
88-
cl.NotBefore = jwt.NewNumericDate(now.Add(-jwt.DefaultLeeway))
89-
cl.Expiry = jwt.NewNumericDate(now.Add(jwt.DefaultLeeway))
90-
h, err := jwt.Signed(cs).Claims(&cl).CompactSerialize()
91-
if err != nil {
92-
return nil, err
93-
}
94-
r.Header.Add("authorization", "Bearer "+h)
69+
// NewRequestWithContext is a wrapper around [http.NewRequestWithContext] that
70+
// sets some defaults in the returned request.
71+
func NewRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*http.Request, error) {
72+
// The one OK use of the normal function.
73+
req, err := http.NewRequestWithContext(ctx, method, url, body)
74+
if err != nil {
75+
return nil, err
76+
}
77+
p, err := os.Executable()
78+
if err != nil {
79+
p = `clair?`
80+
} else {
81+
p = filepath.Base(p)
9582
}
96-
return cs.next.RoundTrip(r)
83+
req.Header.Set("user-agent", fmt.Sprintf("%s/%s", p, cmd.Version))
84+
return req, nil
9785
}

internal/httputil/ratelimiter.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ func RateLimiter(next http.RoundTripper) http.RoundTripper {
2121
// Ratelimiter implements the limiting by using a concurrent map and Limiter
2222
// structs.
2323
type ratelimiter struct {
24-
rt http.RoundTripper
2524
lm sync.Map
25+
rt http.RoundTripper
2626
}
2727

2828
const rateCap = 10

internal/httputil/signer.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package httputil
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/url"
7+
"time"
8+
9+
"github.com/quay/clair/config"
10+
"github.com/quay/zlog"
11+
"gopkg.in/square/go-jose.v2"
12+
"gopkg.in/square/go-jose.v2/jwt"
13+
)
14+
15+
// NewSigner constructs a signer according to the provided Config and claim.
16+
//
17+
// The returned Signer only adds headers for the hosts specified in the
18+
// following spots:
19+
//
20+
// - $.notifier.webhook.target
21+
// - $.notifier.indexer_addr
22+
// - $.notifier.matcher_addr
23+
// - $.matcher.indexer_addr
24+
func NewSigner(ctx context.Context, cfg *config.Config, cl jwt.Claims) (*Signer, error) {
25+
if cfg.Auth.PSK == nil {
26+
zlog.Debug(ctx).
27+
Str("component", "internal/httputil/NewSigner").
28+
Msg("authentication disabled")
29+
return new(Signer), nil
30+
}
31+
s := Signer{
32+
use: make(map[string]struct{}),
33+
claim: cl,
34+
}
35+
if cfg.Notifier.Webhook != nil {
36+
if err := s.Add(ctx, cfg.Notifier.Webhook.Target); err != nil {
37+
return nil, err
38+
}
39+
}
40+
if err := s.Add(ctx, cfg.Notifier.IndexerAddr); err != nil {
41+
return nil, err
42+
}
43+
if err := s.Add(ctx, cfg.Notifier.MatcherAddr); err != nil {
44+
return nil, err
45+
}
46+
if err := s.Add(ctx, cfg.Matcher.IndexerAddr); err != nil {
47+
return nil, err
48+
}
49+
50+
sk := jose.SigningKey{
51+
Algorithm: jose.HS256,
52+
Key: []byte(cfg.Auth.PSK.Key),
53+
}
54+
signer, err := jose.NewSigner(sk, nil)
55+
if err != nil {
56+
return nil, err
57+
}
58+
s.signer = signer
59+
if zlog.Debug(ctx).Enabled() {
60+
as := make([]string, 0, len(s.use))
61+
for a := range s.use {
62+
as = append(as, a)
63+
}
64+
zlog.Debug(ctx).Strs("authorities", as).
65+
Msg("enabling signing for authorities")
66+
}
67+
return &s, nil
68+
}
69+
70+
// Add marks the authority in "uri" as one that expects signed requests.
71+
func (s *Signer) Add(ctx context.Context, uri string) error {
72+
if uri == "" {
73+
return nil
74+
}
75+
u, err := url.Parse(uri)
76+
if err != nil {
77+
return err
78+
}
79+
a := u.Host
80+
s.use[a] = struct{}{}
81+
return nil
82+
}
83+
84+
// Signer signs requests.
85+
type Signer struct {
86+
signer jose.Signer
87+
use map[string]struct{}
88+
claim jwt.Claims
89+
}
90+
91+
// Sign modifies the passed [http.Request] as needed.
92+
func (s *Signer) Sign(ctx context.Context, req *http.Request) error {
93+
if s == nil || s.signer == nil {
94+
return nil
95+
}
96+
host := req.Host
97+
if host == "" {
98+
host = req.URL.Host
99+
}
100+
if _, ok := s.use[host]; !ok {
101+
return nil
102+
}
103+
cl := s.claim
104+
now := time.Now()
105+
cl.IssuedAt = jwt.NewNumericDate(now)
106+
cl.NotBefore = jwt.NewNumericDate(now.Add(-jwt.DefaultLeeway))
107+
cl.Expiry = jwt.NewNumericDate(now.Add(jwt.DefaultLeeway))
108+
h, err := jwt.Signed(s.signer).Claims(&cl).CompactSerialize()
109+
if err != nil {
110+
return err
111+
}
112+
req.Header.Add("authorization", "Bearer "+h)
113+
return nil
114+
}

0 commit comments

Comments
 (0)