Skip to content

Commit cdba2f7

Browse files
authored
Harden app-store broker + supervisor (grants, backoff, rotation, RLIMIT_AS, TOCTOU, extend DoS) (#19)
1 parent 440ff2b commit cdba2f7

23 files changed

Lines changed: 1196 additions & 71 deletions

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,29 @@ First release candidate. Surface is end-to-end working for the
1313
Production deployment against an untrusted publisher requires the
1414
catalog-signing chain landed in a follow-up RC (see "Known gaps").
1515

16+
### Security & hardening — broker + supervisor
17+
18+
- **Broker authorization (deny-by-default).** Every brokered call is
19+
gated before dialing an app socket: the method must be in the target's
20+
`exposes` set, and cross-app `ipc.call` callers must hold a matching
21+
grant (`<app>.<method>`, `<app>.*`, or `*`). New `Service.CallFrom` and
22+
`manifest.ExposesMethod` / `HasGrant`; errors `ErrMethodNotExposed`,
23+
`ErrGrantMissing`.
24+
- **TOCTOU re-verification at spawn.** The binary is re-checked
25+
immediately before `exec` (symlink rejection + sha256), closing the gap
26+
between install-scan and launch.
27+
- **Exponential verify-fail backoff.** Verification failures retry with
28+
capped exponential backoff (was a fixed 30s), consistent with crash-loop
29+
handling.
30+
- **Multi-generation audit log rotation.** `supervisor.log` rotates across
31+
N generations (`AuditLogMaxBackups`, default 3) instead of a single step.
32+
- **Address-space cap (Linux).** Spawned apps get `RLIMIT_AS` alongside
33+
`RLIMIT_NOFILE`, configurable via `Config.ChildMemoryLimitBytes`
34+
(default 4 GiB); no-op on non-Linux.
35+
- **Extension DoS guards.** `pkg/extend` adds per-app hook-dispatch rate
36+
limiting (`Registry.SetRateLimit`, `ErrRateLimited`) and a per-app cap on
37+
dynamic registrations (`ErrTooManyRegistrations`).
38+
1639
### Added — pilotctl
1740

1841
`pilotctl appstore` is the operator surface for the app store.

pkg/extend/extend.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,17 @@ type Registry struct {
115115

116116
mu sync.RWMutex
117117
byPoint map[HookPoint][]Extension
118+
119+
// limiter, when non-nil, rate-limits hook dispatch per app in Run.
120+
// Nil = unlimited (default); enable via SetRateLimit.
121+
limiter *rateLimiter
118122
}
119123

124+
// ErrRateLimited is returned by Run when an app's hook-invocation rate
125+
// budget is exhausted. The chain aborts rather than dispatch into an app
126+
// that's being called too aggressively.
127+
var ErrRateLimited = errors.New("extend: hook rate limit exceeded")
128+
120129
// NewRegistry constructs an empty Registry. dispatch is required.
121130
func NewRegistry(dispatch Dispatcher) *Registry {
122131
if dispatch == nil {
@@ -133,6 +142,38 @@ func NewRegistry(dispatch Dispatcher) *Registry {
133142
}
134143
}
135144

145+
// SetRateLimit enables per-app hook-dispatch rate limiting: each app may
146+
// fire `burst` hook invocations instantly and `ratePerSec` sustained
147+
// thereafter. Once an app's budget is exhausted, Run aborts that app's
148+
// hook with ErrRateLimited. Call with a non-positive rate or burst to
149+
// disable. Intended to be set once at daemon wire-up.
150+
func (r *Registry) SetRateLimit(ratePerSec float64, burst int) {
151+
r.mu.Lock()
152+
defer r.mu.Unlock()
153+
if ratePerSec <= 0 || burst <= 0 {
154+
r.limiter = nil
155+
return
156+
}
157+
r.limiter = newRateLimiter(ratePerSec, burst)
158+
}
159+
160+
// CountForApp returns the number of currently-registered extensions
161+
// belonging to appID across all hook points. Used to bound how many
162+
// dynamic registrations a single app may hold.
163+
func (r *Registry) CountForApp(appID string) int {
164+
r.mu.RLock()
165+
defer r.mu.RUnlock()
166+
n := 0
167+
for _, list := range r.byPoint {
168+
for _, ext := range list {
169+
if ext.AppID == appID {
170+
n++
171+
}
172+
}
173+
}
174+
return n
175+
}
176+
136177
// Register adds one extension to the registry. Returns an error if
137178
// the primitive is shape-invalid, the method is empty, or a flag is
138179
// shaped wrong. Multiple apps may register for the same hook point;
@@ -239,11 +280,17 @@ func (r *Registry) FlagsFor(p HookPoint) []FlagSpec {
239280
// the hook may have populated, then cleared.
240281
func (r *Registry) Run(ctx context.Context, p HookPoint, args HookArgs) (HookArgs, error) {
241282
hooks := r.HooksFor(p)
283+
r.mu.RLock()
284+
lim := r.limiter
285+
r.mu.RUnlock()
242286
current := args
243287
for _, h := range hooks {
244288
if err := ctx.Err(); err != nil {
245289
return current, err
246290
}
291+
if lim != nil && !lim.allow(h.AppID) {
292+
return current, fmt.Errorf("extend %s: hook %s.%s: %w", p, h.AppID, h.Method, ErrRateLimited)
293+
}
247294
next, err := r.dispatch(ctx, h.AppID, h.Method, cloneArgs(current))
248295
if err != nil {
249296
return current, fmt.Errorf("extend %s: hook %s.%s: %w", p, h.AppID, h.Method, err)

pkg/extend/ratelimit.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package extend
2+
3+
import (
4+
"sync"
5+
"time"
6+
)
7+
8+
// rateLimiter is a per-key token bucket used to bound how often the
9+
// registry dispatches into any single app's hooks. A misbehaving or
10+
// hostile app whose hooks fire on every primitive cannot turn the hook
11+
// chain into a DoS amplifier: once its bucket is empty, further hook
12+
// invocations are refused until it refills.
13+
type rateLimiter struct {
14+
mu sync.Mutex
15+
rate float64 // tokens added per second
16+
burst float64 // bucket capacity
17+
now func() time.Time
18+
buckets map[string]*tokenBucket
19+
}
20+
21+
type tokenBucket struct {
22+
tokens float64
23+
last time.Time
24+
}
25+
26+
// newRateLimiter builds a limiter allowing `burst` events instantly and
27+
// `ratePerSec` sustained thereafter, per key.
28+
func newRateLimiter(ratePerSec float64, burst int) *rateLimiter {
29+
return &rateLimiter{
30+
rate: ratePerSec,
31+
burst: float64(burst),
32+
now: time.Now,
33+
buckets: map[string]*tokenBucket{},
34+
}
35+
}
36+
37+
// allow reports whether one event for key may proceed now, consuming a
38+
// token if so. Refills lazily based on elapsed wall-clock time.
39+
func (rl *rateLimiter) allow(key string) bool {
40+
rl.mu.Lock()
41+
defer rl.mu.Unlock()
42+
t := rl.now()
43+
b := rl.buckets[key]
44+
if b == nil {
45+
b = &tokenBucket{tokens: rl.burst, last: t}
46+
rl.buckets[key] = b
47+
}
48+
if elapsed := t.Sub(b.last).Seconds(); elapsed > 0 {
49+
b.tokens += elapsed * rl.rate
50+
if b.tokens > rl.burst {
51+
b.tokens = rl.burst
52+
}
53+
b.last = t
54+
}
55+
if b.tokens >= 1 {
56+
b.tokens--
57+
return true
58+
}
59+
return false
60+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package extend
2+
3+
import (
4+
"context"
5+
"testing"
6+
)
7+
8+
// TestSetRateLimit_DisableClearsLimiter covers the disable branch
9+
// (non-positive rate/burst clears the limiter): a previously-enabled
10+
// limiter is removed and Run stops rate-limiting.
11+
func TestSetRateLimit_DisableClearsLimiter(t *testing.T) {
12+
t.Parallel()
13+
r := NewRegistry(noopDispatch)
14+
if err := r.Register(Extension{AppID: "a", Primitive: PreSendMessage, Method: "h"}); err != nil {
15+
t.Fatal(err)
16+
}
17+
r.SetRateLimit(1, 1) // enable, tiny budget
18+
r.SetRateLimit(0, 0) // disable again
19+
20+
for i := 0; i < 50; i++ {
21+
if _, err := r.Run(context.Background(), PreSendMessage, HookArgs{}); err != nil {
22+
t.Fatalf("disabled limiter must not block (call %d): %v", i, err)
23+
}
24+
}
25+
}

pkg/extend/ratelimit_test.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package extend
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"testing"
8+
"time"
9+
)
10+
11+
// noopDispatch is a Dispatcher that echoes args back with no error.
12+
func noopDispatch(_ context.Context, _, _ string, a HookArgs) (HookArgs, error) {
13+
return a, nil
14+
}
15+
16+
// TestRun_RateLimitsPerApp asserts that once an app's burst budget is
17+
// spent, Run refuses further hook dispatch with ErrRateLimited, and that
18+
// the budget refills as the (injected) clock advances.
19+
func TestRun_RateLimitsPerApp(t *testing.T) {
20+
t.Parallel()
21+
r := NewRegistry(noopDispatch)
22+
if err := r.Register(Extension{AppID: "io.spammer", Primitive: PreSendMessage, Method: "h"}); err != nil {
23+
t.Fatal(err)
24+
}
25+
// 1 token/sec, burst 3.
26+
r.SetRateLimit(1, 3)
27+
28+
// Pin the clock so refill is deterministic.
29+
base := time.Unix(1_000_000, 0)
30+
now := base
31+
r.mu.Lock()
32+
r.limiter.now = func() time.Time { return now }
33+
r.mu.Unlock()
34+
35+
ctx := context.Background()
36+
// First 3 calls consume the burst.
37+
for i := 0; i < 3; i++ {
38+
if _, err := r.Run(ctx, PreSendMessage, HookArgs{}); err != nil {
39+
t.Fatalf("call %d should pass, got %v", i, err)
40+
}
41+
}
42+
// 4th call (no time elapsed) is rate-limited.
43+
if _, err := r.Run(ctx, PreSendMessage, HookArgs{}); !errors.Is(err, ErrRateLimited) {
44+
t.Fatalf("4th call err = %v, want ErrRateLimited", err)
45+
}
46+
// Advance 1s → exactly one token refills → one call passes, next fails.
47+
now = base.Add(time.Second)
48+
if _, err := r.Run(ctx, PreSendMessage, HookArgs{}); err != nil {
49+
t.Fatalf("post-refill call should pass, got %v", err)
50+
}
51+
if _, err := r.Run(ctx, PreSendMessage, HookArgs{}); !errors.Is(err, ErrRateLimited) {
52+
t.Fatalf("call after refill drained err = %v, want ErrRateLimited", err)
53+
}
54+
}
55+
56+
// TestRun_RateLimitIsPerApp confirms one app exhausting its budget does
57+
// not block a different app's hooks.
58+
func TestRun_RateLimitIsPerApp(t *testing.T) {
59+
t.Parallel()
60+
r := NewRegistry(noopDispatch)
61+
_ = r.Register(Extension{AppID: "a", Primitive: PreSendMessage, Method: "h", Order: 1})
62+
_ = r.Register(Extension{AppID: "b", Primitive: PostRecvMessage, Method: "h", Order: 1})
63+
r.SetRateLimit(1, 1)
64+
now := time.Unix(2_000_000, 0)
65+
r.mu.Lock()
66+
r.limiter.now = func() time.Time { return now }
67+
r.mu.Unlock()
68+
69+
ctx := context.Background()
70+
// Drain app "a".
71+
if _, err := r.Run(ctx, PreSendMessage, HookArgs{}); err != nil {
72+
t.Fatal(err)
73+
}
74+
if _, err := r.Run(ctx, PreSendMessage, HookArgs{}); !errors.Is(err, ErrRateLimited) {
75+
t.Fatalf("app a should be limited, got %v", err)
76+
}
77+
// App "b" still has its own full budget.
78+
if _, err := r.Run(ctx, PostRecvMessage, HookArgs{}); err != nil {
79+
t.Fatalf("app b should not be limited, got %v", err)
80+
}
81+
}
82+
83+
// TestRun_NoLimiterByDefault confirms back-compat: without SetRateLimit,
84+
// many invocations all pass.
85+
func TestRun_NoLimiterByDefault(t *testing.T) {
86+
t.Parallel()
87+
r := NewRegistry(noopDispatch)
88+
_ = r.Register(Extension{AppID: "a", Primitive: PreSendMessage, Method: "h"})
89+
for i := 0; i < 1000; i++ {
90+
if _, err := r.Run(context.Background(), PreSendMessage, HookArgs{}); err != nil {
91+
t.Fatalf("unlimited registry should never rate-limit, got %v at %d", err, i)
92+
}
93+
}
94+
}
95+
96+
// TestDaemonHandler_CapsDynamicRegistrations asserts an app cannot exceed
97+
// maxDynamicRegistrationsPerApp dynamic hook registrations.
98+
func TestDaemonHandler_CapsDynamicRegistrations(t *testing.T) {
99+
t.Parallel()
100+
reg := NewRegistry(noopDispatch)
101+
h := NewDaemonHandler(reg, AllowAll)
102+
103+
for i := 0; i < maxDynamicRegistrationsPerApp; i++ {
104+
err := h.Register("io.greedy", Extension{
105+
Primitive: PreSendMessage,
106+
Method: fmt.Sprintf("m%d", i),
107+
})
108+
if err != nil {
109+
t.Fatalf("registration %d should succeed, got %v", i, err)
110+
}
111+
}
112+
// One past the cap must be refused.
113+
err := h.Register("io.greedy", Extension{Primitive: PreSendMessage, Method: "overflow"})
114+
if !errors.Is(err, ErrTooManyRegistrations) {
115+
t.Fatalf("over-cap registration err = %v, want ErrTooManyRegistrations", err)
116+
}
117+
// A different app is unaffected.
118+
if err := h.Register("io.modest", Extension{Primitive: PreSendMessage, Method: "m"}); err != nil {
119+
t.Fatalf("other app should still register, got %v", err)
120+
}
121+
// After unregistering one of greedy's hooks, it can register again.
122+
reg.UnregisterOne("io.greedy", PreSendMessage, "m0")
123+
if err := h.Register("io.greedy", Extension{Primitive: PreSendMessage, Method: "again"}); err != nil {
124+
t.Fatalf("after freeing a slot, registration should succeed, got %v", err)
125+
}
126+
}

pkg/extend/runtime.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,18 @@ var DenyAll Permission = PermissionFunc(func(string, HookPoint) bool { return fa
3636
// configured Permission rejects the request.
3737
var ErrPermissionDenied = errors.New("extend: permission denied")
3838

39+
// maxDynamicRegistrationsPerApp bounds how many hooks a single app may
40+
// hold at once via the runtime-register IPC. Without a cap, an app
41+
// permitted to register dynamically could register unboundedly and
42+
// exhaust memory / slow every primitive's hook lookup — a DoS. The
43+
// cap is generous (a well-behaved app registers a handful of hooks)
44+
// while refusing pathological growth.
45+
const maxDynamicRegistrationsPerApp = 32
46+
47+
// ErrTooManyRegistrations is returned by DaemonHandler.Register when an
48+
// app already holds maxDynamicRegistrationsPerApp hooks.
49+
var ErrTooManyRegistrations = errors.New("extend: too many dynamic registrations for app")
50+
3951
// DaemonHandler is the runtime-side IPC surface the daemon exposes to
4052
// installed apps so they can add/remove their own hooks dynamically
4153
// (within the bounds Permission allows). Apps call these methods via
@@ -68,6 +80,11 @@ func (h *DaemonHandler) Register(appID string, ext Extension) error {
6880
if !h.perms.CanRegister(appID, ext.Primitive) {
6981
return fmt.Errorf("%w: %s cannot register %s", ErrPermissionDenied, appID, ext.Primitive)
7082
}
83+
// Bound the number of hooks one app may hold to prevent unbounded
84+
// dynamic registration (memory + per-primitive lookup-cost DoS).
85+
if h.reg.CountForApp(appID) >= maxDynamicRegistrationsPerApp {
86+
return fmt.Errorf("%w: %s holds %d (max %d)", ErrTooManyRegistrations, appID, h.reg.CountForApp(appID), maxDynamicRegistrationsPerApp)
87+
}
7188
return h.reg.Register(ext)
7289
}
7390

0 commit comments

Comments
 (0)