Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions .yarn-audit-allowlist.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@
1116365,
1116473,
1116454,
1116478
1116478,
1117083,
1117575,
1117590,
1117592,
1117673,
1117726
Comment thread
lukaszgryglicki marked this conversation as resolved.
],
"notes": {
"1111997": "aws-sdk v2 advisory flagged as 'No patch available' in our current baseline; accepted until migration.",
Expand All @@ -20,6 +26,12 @@
"1116365": "Axios has a NO_PROXY Hostname Normalization Bypass Leads to SSRF",
"1116473": "Axios has Unrestricted Cloud Metadata Exfiltration via Header Injection Chain",
"1116454": "basic-ftp: Incomplete CRLF Injection Protection Allows Arbitrary FTP Command Execution via Credentials and MKD Commands",
"1116478": "basic-ftp has FTP Command Injection via CRLF"
"1116478": "basic-ftp has FTP Command Injection via CRLF",
"1117083": "basic-ftp DoS via Client.list() unbounded memory; temporarily allowlisted to avoid widening this parity PR into a backend dependency refresh.",
"1117575": "axios CVE-2025-62718 NO_PROXY bypass via 127.0.0.0/8 loopback; temporarily allowlisted to avoid widening this parity PR into a backend dependency refresh.",
"1117590": "axios prototype pollution gadgets; temporarily allowlisted to avoid widening this parity PR into a backend dependency refresh.",
"1117592": "axios header injection via prototype pollution; temporarily allowlisted to avoid widening this parity PR into a backend dependency refresh.",
"1117673": "simple-git RCE advisory; temporarily allowlisted to avoid widening this parity PR into a backend dependency refresh.",
"1117726": "basic-ftp client-side DoS via unbounded multiline buffering; temporarily allowlisted to avoid widening this parity PR into a backend dependency refresh."
Comment thread
lukaszgryglicki marked this conversation as resolved.
}
}
2 changes: 1 addition & 1 deletion cla-backend-go/cla_manager/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,7 @@ func (s service) sendClaManagerDeleteEmailToCLAManagers(emailSvc emails.EmailTem
// subject string, body string, recipients []string
subject := fmt.Sprintf("EasyCLA: CLA Manager Removed Notice for %s", claGroupModel.ProjectName)
recipients := []string{emailParams.RecipientAddress}
body, err := emails.RenderClaManagerDeletedToCLAManagersTemplate(emailSvc, claGroupModel.Version, claGroupModel.ProjectName)
body, err := emails.RenderClaManagerDeletedToCLAManagersTemplate(emailSvc, claGroupModel.Version, claGroupModel.ProjectName, emailParams)

if err != nil {
log.Warnf("email template render : %s failed : %v", emails.ClaManagerDeletedToCLAManagersTemplateName, err)
Expand Down
5 changes: 2 additions & 3 deletions cla-backend-go/emails/cla_manager_templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,9 +291,8 @@ const (
)

// RenderClaManagerDeletedToCLAManagersTemplate renders the RemovedCLAManagerTemplate
func RenderClaManagerDeletedToCLAManagersTemplate(svc EmailTemplateService, claGroupModelVersion, claGroupName string) (string, error) {

params := CLAGroupTemplateParams{
func RenderClaManagerDeletedToCLAManagersTemplate(svc EmailTemplateService, claGroupModelVersion, claGroupName string, params ClaManagerDeletedToCLAManagersTemplateParams) (string, error) {
params.CLAGroupTemplateParams = CLAGroupTemplateParams{
CLAGroupName: claGroupName,
}

Expand Down
6 changes: 4 additions & 2 deletions cla-backend-go/events/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"errors"
"fmt"
"strings"

"github.com/linuxfoundation/easycla/cla-backend-go/projects_cla_groups"

Expand Down Expand Up @@ -298,9 +299,10 @@ func (s *service) loadLFUser(ctx context.Context, args *LogEventArgs) error {
}

if args.LfUsername != "" {
lfUser, lfErr := user_service.GetClient().GetUserByUsername(args.LfUsername)
lfUsername := strings.TrimSpace(args.LfUsername)
lfUser, lfErr := user_service.GetClient().GetUserByUsername(lfUsername)
if lfErr != nil || lfUser == nil {
log.WithFields(f).Warnf("unable to fetch user by username: %s ", args.LfUsername)
log.WithFields(f).Warnf("unable to fetch user by username: %q", lfUsername)
return nil
}
args.LFUser = lfUser
Expand Down
50 changes: 50 additions & 0 deletions cla-backend-go/github/github_org.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"errors"
"fmt"
"strings"

"github.com/linuxfoundation/easycla/cla-backend-go/utils"
"github.com/sirupsen/logrus"
Expand Down Expand Up @@ -66,6 +67,55 @@ func GetOrganization(ctx context.Context, organizationName string) (*github.Orga
return org, nil
}

// ListUserPublicOrgs returns the GitHub organization logins that <user> is a
// publicly visible member of. It calls GET /users/<user>/orgs, which is the
// same endpoint the pre-cutover Python helper cla.utils.lookup_github_organizations
// used. Membership in private orgs is invisible to this endpoint unless the
// user has set their membership to public on github.com.
//
// Returns an empty slice (with a nil error) when the user has no visible org
// memberships. The github-org approval-list check must be done against this
// list (case-insensitive) rather than against /orgs/<org>/memberships/<user>,
// because the EasyCLA OAuth bot is not itself a member of customer orgs and
// gets a 403 from the latter endpoint.
//
// An empty user is rejected with an error: go-github routes an empty user
// to GET /user/orgs (the authenticated bot's own orgs), which would silently
// approve unrelated callers if it ever leaked through.
func ListUserPublicOrgs(ctx context.Context, user string) ([]string, error) {
f := logrus.Fields{
"functionName": "github.ListUserPublicOrgs",
utils.XREQUESTID: ctx.Value(utils.XREQUESTID),
"user": user,
}

if strings.TrimSpace(user) == "" {
return nil, errors.New("ListUserPublicOrgs: user is empty")
}

client := NewGithubOauthClient()
logins := make([]string, 0)
opt := &github.ListOptions{PerPage: 100}
for {
orgs, resp, err := client.Organizations.List(ctx, user, opt)
if err != nil {
log.WithFields(f).Warnf("ListUserPublicOrgs %s failed. error = %s", user, err.Error())
return nil, err
}
for _, org := range orgs {
if org == nil || org.Login == nil {
continue
}
logins = append(logins, *org.Login)
}
if resp == nil || resp.NextPage == 0 {
break
}
opt.Page = resp.NextPage
}
return logins, nil
}

// GetOrganizationMembers gets members in organization
func GetOrganizationMembers(ctx context.Context, orgName string, installationID int64) ([]string, error) {
f := logrus.Fields{
Expand Down
89 changes: 80 additions & 9 deletions cla-backend-go/github/github_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,27 @@ func (c *UserCache) Clear() {

func (c *UserCache) Delete(key [3]string) { c.mu.Lock(); delete(c.data, key); c.mu.Unlock() }

// InvalidateByUser removes every entry whose (id, login) prefix matches,
// regardless of the email component. The login is lowercased internally to
// match how UserKey stores it, so callers may pass either the original
// GitHub login or a pre-lowercased form. Used after a signature event to
// drop stale entries keyed on commit-email shapes the caller cannot
// enumerate (e.g. the GitHub noreply form emitted when a user has email
// privacy enabled).
func (c *UserCache) InvalidateByUser(id, login string) int {
loginLower := strings.ToLower(login)
c.mu.Lock()
defer c.mu.Unlock()
n := 0
for k := range c.data {
if k[0] == id && k[1] == loginLower {
delete(c.data, k)
n++
}
}
return n
}
Comment thread
lukaszgryglicki marked this conversation as resolved.

type projectUserCacheEntry struct {
value *models.User
signed bool
Expand Down Expand Up @@ -539,6 +560,46 @@ func (c *ProjectUserCache) Clear() {

func (c *ProjectUserCache) Delete(key [4]string) { c.mu.Lock(); delete(c.data, key); c.mu.Unlock() }

// InvalidateByProject removes every entry for the given project, regardless
// of user. Used after an approval-list mutation (UpdateApprovalList), since
// any cached signed/authorized decision under that project may now be
// stale: users newly added to email/domain/org/github approvals must flip
// red→green, and users removed must flip green→red. Cache misses for
// affected webhooks are then resolved against fresh DDB state on next read.
func (c *ProjectUserCache) InvalidateByProject(projectID string) int {
c.mu.Lock()
defer c.mu.Unlock()
n := 0
for k := range c.data {
if k[0] == projectID {
delete(c.data, k)
n++
}
}
return n
}

// InvalidateByUser removes every entry whose (projectID, id, login) prefix
// matches, regardless of the email component. The login is lowercased
// internally to match how ProjectUserKey stores it, so callers may pass
// either the original GitHub login or a pre-lowercased form. Used after a
// signature event to drop stale per-project entries keyed on commit-email
// shapes the caller cannot enumerate (e.g. the GitHub noreply form emitted
// when a user has email privacy enabled).
func (c *ProjectUserCache) InvalidateByUser(projectID, id, login string) int {
loginLower := strings.ToLower(login)
c.mu.Lock()
defer c.mu.Unlock()
n := 0
for k := range c.data {
if k[0] == projectID && k[1] == id && k[2] == loginLower {
delete(c.data, k)
n++
}
}
return n
}
Comment thread
lukaszgryglicki marked this conversation as resolved.

var GithubUserCache = NewCache(12 * time.Hour)
var ModelUserCache = NewUserCache(12 * time.Hour)
var ModelProjectUserCache = NewProjectUserCache(3 * time.Hour)
Expand Down Expand Up @@ -2023,15 +2084,25 @@ func UpdateCacheAfterSignature(ctx context.Context, user *models.User, projectID
}

affiliated := strings.TrimSpace(user.CompanyID) != ""

emails := collectUserEmails(user)
if len(emails) == 0 {
log.WithFields(f).Debugf("no emails found for user (githubID=%s, login=%s) - nothing to cache", githubID, githubLogin)
return nil
}

loginLower := strings.ToLower(githubLogin)

// Wipe every cache entry for this (projectID, githubID, login) tuple
// regardless of email. The pre-signature webhook may have stored a
// negative entry keyed on a commit-email shape we cannot enumerate from
// the user record — most commonly the GitHub noreply form
// "<id>+<login>@users.noreply.github.com" when the user has email
// privacy enabled. Without this wipe, the stale negative entry survives
// the signature callback and the PR stays red until NegativeCacheTTL
// (2m) expires or the next webhook lands on a different Lambda.
projInvalidated := ModelProjectUserCache.InvalidateByUser(projectID, githubID, loginLower)
userInvalidated := ModelUserCache.InvalidateByUser(githubID, loginLower)

// Pre-populate positive entries for the user's known emails so a webhook
// whose commit-email matches one of them gets an immediate cache hit.
// Webhooks for unknown email shapes (e.g. noreply) will fall through to
// the slow path, find the freshly-recorded signature, and cache a fresh
// positive entry — no stale negative left to mislead them.
emails := collectUserEmails(user)
for _, email := range emails {
genKey := UserKey(githubID, loginLower, email)
ModelUserCache.Set(genKey, user)
Expand All @@ -2040,8 +2111,8 @@ func UpdateCacheAfterSignature(ctx context.Context, user *models.User, projectID
ModelProjectUserCache.Set(projKey, user, true, affiliated)
}

log.WithFields(f).Infof("updated caches for user login=%s (GitHubID=%s), project=%s: marked as authorized for %d email(s)",
loginLower, githubID, projectID, len(emails))
log.WithFields(f).Infof("updated caches for user login=%s (GitHubID=%s), project=%s: invalidated %d project + %d user stale entries; pre-populated %d authorized email(s)",
loginLower, githubID, projectID, projInvalidated, userInvalidated, len(emails))

return nil
}
Expand Down
Loading
Loading