Verify signature ownership in esign accept/record-event flows

Any self-provisioned trust center visitor could accept another
visitor's NDA signature or inject audit-trail events into it by
supplying its GID, since AcceptSignature and RecordEvent trusted the
client-supplied signature ID without checking it belonged to the
caller (GHSA-22xj-f767-ppw6). SignerEmail/ActorEmail are always
derived from the verified session identity, never client input, so
comparing them against the signature's stored SignerEmail in
pkg/esign/service.go closes the hole at its root without touching the
resolver-level authorization already in place elsewhere.

Adds an e2e regression test that self-provisions two trust center
visitors through the real magic-link flow and confirms one cannot
touch the other's signature.

Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-07-06 10:13:06 +02:00
committed by Sacha Al Himdani
parent 83e7b3bdd4
commit f20c3d73d2
6 changed files with 352 additions and 5 deletions

View File

@@ -400,10 +400,16 @@ func (c *Client) inviteUser(profileID gid.GID) {
}
func (c *Client) getActivationToken(email string) string {
return c.pollForLinkToken(fmt.Sprintf("to:%s subject:\"Invitation to join\"", email))
}
// pollForLinkToken polls mailpit for a message matching searchQuery and
// returns the first "token" query parameter found among its links.
func (c *Client) pollForLinkToken(searchQuery string) string {
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
searchMails, err := c.SearchMails(fmt.Sprintf("to:%s subject:\"Invitation to join\"", email))
searchMails, err := c.SearchMails(searchQuery)
require.NoError(c.T, err, "mailpit messages search failed")
for _, msg := range searchMails.Messages {
@@ -414,9 +420,8 @@ func (c *Client) getActivationToken(email string) string {
linkURL, err := url.Parse(link.URL)
require.NoError(c.T, err, "mailpit link invalid URL")
query := linkURL.Query()
if query.Get("token") != "" {
return query.Get("token")
if token := linkURL.Query().Get("token"); token != "" {
return token
}
}
}
@@ -424,7 +429,7 @@ func (c *Client) getActivationToken(email string) string {
time.Sleep(100 * time.Millisecond)
}
c.T.Logf("activation token not found")
c.T.Logf("link token not found for query %q", searchQuery)
c.T.FailNow()
return ""
@@ -530,6 +535,66 @@ func NewClientWithNewSession(t testing.TB, from *Client) *Client {
return client
}
// SelfProvisionTrustCenterVisitor signs a brand-new email up as a trust
// center visitor through the public magic-link flow (send + verify), the
// same path a real visitor takes. It returns a Client bound to that
// visitor's own session and cookie jar, scoped to no organization.
func SelfProvisionTrustCenterVisitor(t testing.TB, trustCenterID string) *Client {
t.Helper()
jar, err := cookiejar.New(nil)
require.NoError(t, err, "cannot create cookie jar")
email := fmt.Sprintf("visitor-%s@e2e.probo.test", generateUniqueID())
visitor := &Client{
T: t,
baseURL: GetBaseURL(),
mailpitBaseURL: GetMailpitBaseURL(),
email: email,
httpClient: &http.Client{
Jar: jar,
Timeout: 30 * time.Second,
},
}
visitor.sendMagicLink(trustCenterID, email)
token := visitor.pollForLinkToken(fmt.Sprintf("to:%s", email))
visitor.verifyMagicLink(trustCenterID, token)
return visitor
}
func (c *Client) sendMagicLink(trustCenterID, email string) {
const query = `
mutation($input: SendMagicLinkInput!) {
sendMagicLink(input: $input) {
success
}
}
`
err := c.ExecuteTrust(trustCenterID, query, map[string]any{
"input": map[string]any{"email": email},
}, nil)
require.NoError(c.T, err, "sendMagicLink mutation failed")
}
func (c *Client) verifyMagicLink(trustCenterID, token string) {
const query = `
mutation($input: VerifyMagicLinkInput!) {
verifyMagicLink(input: $input) {
continue
}
}
`
err := c.ExecuteTrust(trustCenterID, query, map[string]any{
"input": map[string]any{"token": token},
}, nil)
require.NoError(c.T, err, "verifyMagicLink mutation failed")
}
func (c *Client) GetEmail() string {
return c.email
}