Skip to content

Commit 080dc61

Browse files
committed
fix(member): reject non-IP address for members in F5-backed pools
A member with a hostname as its address causes the F5 AS3 declaration driver to receive a value that violates the f5ip format constraint, resulting in HTTP 422 on every declaration POST and blocking all F5 syncs globally. Validate at the API layer (POST and PUT) that the address is a valid IPv4 or IPv6 address when the member's pool is associated with an F5 domain. Non-F5-backed pools are unaffected. Closes #1227
1 parent e2dc7a4 commit 080dc61

3 files changed

Lines changed: 178 additions & 0 deletions

File tree

internal/controller/member.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ package controller
77
import (
88
"errors"
99
"fmt"
10+
"net"
1011
"strings"
1112

1213
"github.com/apex/log"
1314

1415
"github.com/go-openapi/runtime/middleware"
16+
"github.com/go-openapi/strfmt"
1517
"github.com/go-sql-driver/mysql"
1618
"github.com/jackc/pgerrcode"
1719
"github.com/jmoiron/sqlx"
@@ -90,6 +92,14 @@ func (c MemberController) PostMembers(params members.PostMembersParams) middlewa
9092
member.PoolID = &pool.ID
9193
member.ProjectID = &projectID
9294

95+
f5, err := poolHasF5Domain(c.db, pool.ID)
96+
if err != nil {
97+
panic(err)
98+
}
99+
if f5 && net.ParseIP(*member.Address) == nil {
100+
return members.NewPostMembersBadRequest().WithPayload(utils.InvalidMemberAddressForF5)
101+
}
102+
93103
// Set default values
94104
if err := utils.SetModelDefaults(member); err != nil {
95105
panic(err)
@@ -160,6 +170,16 @@ func (c MemberController) PutMembersMemberID(params members.PutMembersMemberIDPa
160170
return members.NewPutMembersMemberIDBadRequest().WithPayload(utils.PoolIDImmutable)
161171
}
162172

173+
if params.Member.Member.Address != nil {
174+
f5, err := poolHasF5Domain(c.db, *member.PoolID)
175+
if err != nil {
176+
panic(err)
177+
}
178+
if f5 && net.ParseIP(*params.Member.Member.Address) == nil {
179+
return members.NewPutMembersMemberIDBadRequest().WithPayload(utils.InvalidMemberAddressForF5)
180+
}
181+
}
182+
163183
params.Member.Member.ID = params.MemberID
164184
if err := db.TxExecute(c.db, func(tx *sqlx.Tx) error {
165185
sql := `
@@ -231,3 +251,16 @@ func PopulateMember(db *sqlx.DB, member *models.Member, fields []string) error {
231251
}
232252
return nil
233253
}
254+
255+
func poolHasF5Domain(db *sqlx.DB, poolID strfmt.UUID) (bool, error) {
256+
var count int
257+
sql := db.Rebind(`
258+
SELECT COUNT(d.id) FROM domain d
259+
JOIN domain_pool_relation dpr ON d.id = dpr.domain_id
260+
WHERE dpr.pool_id = ? AND d.provider = 'f5'
261+
`)
262+
if err := db.Get(&count, sql, poolID); err != nil {
263+
return false, err
264+
}
265+
return count > 0, nil
266+
}

internal/controller/member_test.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,35 @@ import (
99
"net/http/httptest"
1010

1111
"github.com/go-openapi/runtime"
12+
"github.com/go-openapi/strfmt"
13+
"github.com/go-openapi/swag/conv"
1214
"github.com/stretchr/testify/assert"
1315

16+
"github.com/sapcc/andromeda/models"
17+
"github.com/sapcc/andromeda/restapi/operations/domains"
1418
"github.com/sapcc/andromeda/restapi/operations/members"
1519
)
1620

21+
func (t *SuiteTest) createF5Domain() strfmt.UUID {
22+
fqdn := strfmt.Hostname("f5test.com")
23+
domain := domains.PostDomainsBody{
24+
Domain: &models.Domain{
25+
Fqdn: &fqdn,
26+
Name: conv.Pointer("f5test"),
27+
Provider: conv.Pointer("f5"),
28+
},
29+
}
30+
31+
res := t.c.Domains.PostDomains(domains.PostDomainsParams{Domain: domain})
32+
rr := httptest.NewRecorder()
33+
res.WriteResponse(rr, runtime.JSONProducer())
34+
assert.Equal(t.T(), http.StatusCreated, rr.Code, rr.Body)
35+
36+
domainResponse := domains.PostDomainsCreatedBody{}
37+
_ = domainResponse.UnmarshalBinary(rr.Body.Bytes())
38+
return domainResponse.Domain.ID
39+
}
40+
1741
func (t *SuiteTest) TestMembers() {
1842
mc := t.c.Members
1943
rr := httptest.NewRecorder()
@@ -76,3 +100,123 @@ func (t *SuiteTest) TestMembers() {
76100
t.FailNow(err.Error())
77101
}
78102
}
103+
104+
func (t *SuiteTest) TestMembersF5AddressValidation() {
105+
mc := t.c.Members
106+
defer t.cleanupDomains()
107+
defer t.cleanupPools()
108+
109+
// Pool with F5 domain attached
110+
domainID := t.createF5Domain()
111+
f5PoolID := t.createPool([]strfmt.UUID{domainID})
112+
113+
// Pool with Akamai domain attached
114+
akamaiDomainID := t.createDomain()
115+
akamaiPoolID := t.createPool([]strfmt.UUID{akamaiDomainID})
116+
117+
// Pool with no domain
118+
plainPoolID := t.createPool(nil)
119+
120+
t.Run("F5 pool: valid IPv4 address is accepted", func() {
121+
body := members.PostMembersBody{}
122+
_ = body.UnmarshalBinary([]byte(`{ "member": { "address": "192.0.2.1", "port": 80 } }`))
123+
body.Member.PoolID = &f5PoolID
124+
125+
res := mc.PostMembers(members.PostMembersParams{Member: body})
126+
rr := httptest.NewRecorder()
127+
res.WriteResponse(rr, runtime.JSONProducer())
128+
assert.Equal(t.T(), http.StatusCreated, rr.Code, rr.Body)
129+
130+
// Cleanup member
131+
resp := members.PostMembersCreatedBody{}
132+
_ = resp.UnmarshalBinary(rr.Body.Bytes())
133+
mc.DeleteMembersMemberID(members.DeleteMembersMemberIDParams{MemberID: resp.Member.ID})
134+
})
135+
136+
t.Run("F5 pool: valid IPv6 address is accepted", func() {
137+
body := members.PostMembersBody{}
138+
_ = body.UnmarshalBinary([]byte(`{ "member": { "address": "::1", "port": 80 } }`))
139+
body.Member.PoolID = &f5PoolID
140+
141+
res := mc.PostMembers(members.PostMembersParams{Member: body})
142+
rr := httptest.NewRecorder()
143+
res.WriteResponse(rr, runtime.JSONProducer())
144+
assert.Equal(t.T(), http.StatusCreated, rr.Code, rr.Body)
145+
146+
resp := members.PostMembersCreatedBody{}
147+
_ = resp.UnmarshalBinary(rr.Body.Bytes())
148+
mc.DeleteMembersMemberID(members.DeleteMembersMemberIDParams{MemberID: resp.Member.ID})
149+
})
150+
151+
t.Run("F5 pool: hostname address is rejected", func() {
152+
body := members.PostMembersBody{}
153+
_ = body.UnmarshalBinary([]byte(`{ "member": { "address": "my.server.example.com", "port": 80 } }`))
154+
body.Member.PoolID = &f5PoolID
155+
156+
res := mc.PostMembers(members.PostMembersParams{Member: body})
157+
rr := httptest.NewRecorder()
158+
res.WriteResponse(rr, runtime.JSONProducer())
159+
assert.Equal(t.T(), http.StatusBadRequest, rr.Code, rr.Body)
160+
})
161+
162+
t.Run("Akamai pool: hostname address is allowed", func() {
163+
body := members.PostMembersBody{}
164+
_ = body.UnmarshalBinary([]byte(`{ "member": { "address": "my.server.example.com", "port": 80 } }`))
165+
body.Member.PoolID = &akamaiPoolID
166+
167+
res := mc.PostMembers(members.PostMembersParams{Member: body})
168+
rr := httptest.NewRecorder()
169+
res.WriteResponse(rr, runtime.JSONProducer())
170+
assert.Equal(t.T(), http.StatusCreated, rr.Code, rr.Body)
171+
172+
resp := members.PostMembersCreatedBody{}
173+
_ = resp.UnmarshalBinary(rr.Body.Bytes())
174+
mc.DeleteMembersMemberID(members.DeleteMembersMemberIDParams{MemberID: resp.Member.ID})
175+
})
176+
177+
t.Run("Pool with no domain: hostname address is allowed", func() {
178+
body := members.PostMembersBody{}
179+
_ = body.UnmarshalBinary([]byte(`{ "member": { "address": "my.server.example.com", "port": 80 } }`))
180+
body.Member.PoolID = &plainPoolID
181+
182+
res := mc.PostMembers(members.PostMembersParams{Member: body})
183+
rr := httptest.NewRecorder()
184+
res.WriteResponse(rr, runtime.JSONProducer())
185+
assert.Equal(t.T(), http.StatusCreated, rr.Code, rr.Body)
186+
187+
resp := members.PostMembersCreatedBody{}
188+
_ = resp.UnmarshalBinary(rr.Body.Bytes())
189+
mc.DeleteMembersMemberID(members.DeleteMembersMemberIDParams{MemberID: resp.Member.ID})
190+
})
191+
192+
t.Run("F5 pool: PUT with hostname address is rejected", func() {
193+
// Create member with a valid IP first
194+
createBody := members.PostMembersBody{}
195+
_ = createBody.UnmarshalBinary([]byte(`{ "member": { "address": "10.0.0.1", "port": 80 } }`))
196+
createBody.Member.PoolID = &f5PoolID
197+
198+
createRes := mc.PostMembers(members.PostMembersParams{Member: createBody})
199+
createRR := httptest.NewRecorder()
200+
createRes.WriteResponse(createRR, runtime.JSONProducer())
201+
assert.Equal(t.T(), http.StatusCreated, createRR.Code, createRR.Body)
202+
203+
created := members.PostMembersCreatedBody{}
204+
_ = created.UnmarshalBinary(createRR.Body.Bytes())
205+
memberID := created.Member.ID
206+
207+
// Attempt to update address to a hostname
208+
updateBody := members.PutMembersMemberIDBody{}
209+
hostname := "bad.hostname.example.com"
210+
updateBody.Member = &models.Member{Address: &hostname}
211+
212+
putRes := mc.PutMembersMemberID(members.PutMembersMemberIDParams{
213+
MemberID: memberID,
214+
Member: updateBody,
215+
})
216+
putRR := httptest.NewRecorder()
217+
putRes.WriteResponse(putRR, runtime.JSONProducer())
218+
assert.Equal(t.T(), http.StatusBadRequest, putRR.Code, putRR.Body)
219+
220+
mc.DeleteMembersMemberID(members.DeleteMembersMemberIDParams{MemberID: memberID})
221+
})
222+
}

internal/utils/errors.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ var (
2828
MissingFQDN = &models.Error{Code: 400, Message: "invalid value for 'fqdn': 'fqdn' is required"}
2929
MissingProvider = &models.Error{Code: 400, Message: "invalid value for 'provider': 'provider' is required"}
3030
MissingAddressOrPort = &models.Error{Code: 400, Message: "invalid value for 'address' and 'port': 'address' and 'port' are required"}
31+
InvalidMemberAddressForF5 = &models.Error{Code: 400, Message: "invalid value for 'address': must be a valid IPv4 or IPv6 address for pools associated with F5 domains"}
3132
FQDNImmutable = &models.Error{Code: 400, Message: "invalid value for 'fqdn': change of immutable attribute 'fqdn' not allowed"}
3233
RestrictedDatacenterProvider = &models.Error{Code: 400, Message: "invalid value for 'provider': project-specific f5 datacenters are not supported; please use those with scope=public already available"}
3334
MySQLForeignKeyViolation = &mysql.MySQLError{Number: 1451}

0 commit comments

Comments
 (0)