feat: add secure token handling for reusable workflows
- Add host fallback to use GitHubInstance config when 'uses' has no host - Implement same-instance check to prevent token leakage to external hosts - Fix CloneURL() to properly handle trailing slashes in base URLs - Add isSameInstance() helper to normalize and compare instance URLs - Update tests to use FORGEJO_ACTIONS_TOKEN and add same-instance tests
This commit is contained in:
parent
81e10b8ffa
commit
073b650dd0
3 changed files with 173 additions and 19 deletions
|
|
@ -65,14 +65,24 @@ func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
|||
return common.NewErrorExecutor(fmt.Errorf("expected format {owner}/{repo}/.{git_platform}/workflows/{filename}@{ref}. Actual '%s' Input string was not in a correct format", url.Path))
|
||||
}
|
||||
|
||||
// Host fallback: if 'uses' has no host specified, use the instance from configuration (rc.Config.GitHubInstance)
|
||||
if remoteReusableWorkflow.URL == "" {
|
||||
remoteReusableWorkflow.URL = rc.Config.GitHubInstance
|
||||
}
|
||||
|
||||
// uses with safe filename makes the target directory look something like this {owner}-{repo}-.github-workflows-{filename}@{ref}
|
||||
// instead we will just use {owner}-{repo}@{ref} as our target directory. This should also improve performance when we are using
|
||||
// multiple reusable workflows from the same repository and ref since for each workflow we won't have to clone it again
|
||||
filename := fmt.Sprintf("%s/%s@%s", remoteReusableWorkflow.Org, remoteReusableWorkflow.Repo, remoteReusableWorkflow.Ref)
|
||||
workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(filename))
|
||||
|
||||
// If the repository is private, we need a token to clone it
|
||||
token := rc.Config.GetToken()
|
||||
// Provide token only if the reusable workflow is from the same instance.
|
||||
// This allows cloning private repositories from the current Forgejo instance,
|
||||
// while avoiding token leakage to external instances.
|
||||
token := ""
|
||||
if isSameInstance(remoteReusableWorkflow.URL, rc.Config.GitHubInstance) {
|
||||
token = rc.Config.GetToken()
|
||||
}
|
||||
|
||||
if rc.Config.ActionCache != nil {
|
||||
return newActionCacheReusableWorkflowExecutor(rc, filename, remoteReusableWorkflow)
|
||||
|
|
@ -198,11 +208,12 @@ type remoteReusableWorkflow struct {
|
|||
}
|
||||
|
||||
func (r *remoteReusableWorkflow) CloneURL() string {
|
||||
base := strings.TrimSuffix(r.URL, "/")
|
||||
// In Gitea, r.URL always has the protocol prefix, we don't need to add extra prefix in this case.
|
||||
if strings.HasPrefix(r.URL, "http://") || strings.HasPrefix(r.URL, "https://") {
|
||||
return fmt.Sprintf("%s/%s/%s", r.URL, r.Org, r.Repo)
|
||||
if strings.HasPrefix(base, "http://") || strings.HasPrefix(base, "https://") {
|
||||
return fmt.Sprintf("%s/%s/%s", base, r.Org, r.Repo)
|
||||
}
|
||||
return fmt.Sprintf("https://%s/%s/%s", r.URL, r.Org, r.Repo)
|
||||
return fmt.Sprintf("https://%s/%s/%s", base, r.Org, r.Repo)
|
||||
}
|
||||
|
||||
func (r *remoteReusableWorkflow) FilePath() string {
|
||||
|
|
@ -229,3 +240,19 @@ func newRemoteReusableWorkflowWithPlat(url, uses string) *remoteReusableWorkflow
|
|||
URL: url,
|
||||
}
|
||||
}
|
||||
|
||||
// isSameInstance checks if two URLs/hosts refer to the same instance.
|
||||
// Normalizes protocol prefixes, trailing slashes, and case.
|
||||
func isSameInstance(urlOrHost, instance string) bool {
|
||||
normalize := func(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimSuffix(s, "/")
|
||||
if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") {
|
||||
if u, err := url.Parse(s); err == nil && u.Host != "" {
|
||||
return strings.ToLower(u.Host)
|
||||
}
|
||||
}
|
||||
return strings.ToLower(s)
|
||||
}
|
||||
return normalize(urlOrHost) == normalize(instance)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,20 +6,20 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestConfig_GetToken verifies token priority: ACTIONS_TOKEN > GITEA_TOKEN > GITHUB_TOKEN
|
||||
// TestConfig_GetToken verifies token priority: FORGEJO_ACTIONS_TOKEN > GITEA_TOKEN > GITHUB_TOKEN
|
||||
func TestConfig_GetToken(t *testing.T) {
|
||||
t.Run("returns ACTIONS_TOKEN when all tokens present", func(t *testing.T) {
|
||||
t.Run("returns FORGEJO_ACTIONS_TOKEN when all tokens present", func(t *testing.T) {
|
||||
c := &Config{
|
||||
Secrets: map[string]string{
|
||||
"GITHUB_TOKEN": "github-token",
|
||||
"GITEA_TOKEN": "gitea-token",
|
||||
"ACTIONS_TOKEN": "actions-token",
|
||||
"GITHUB_TOKEN": "github-token",
|
||||
"GITEA_TOKEN": "gitea-token",
|
||||
"FORGEJO_ACTIONS_TOKEN": "forgejo-actions-token",
|
||||
},
|
||||
}
|
||||
assert.Equal(t, "actions-token", c.GetToken())
|
||||
assert.Equal(t, "forgejo-actions-token", c.GetToken())
|
||||
})
|
||||
|
||||
t.Run("returns GITEA_TOKEN when ACTIONS_TOKEN absent", func(t *testing.T) {
|
||||
t.Run("returns GITEA_TOKEN when FORGEJO_ACTIONS_TOKEN absent", func(t *testing.T) {
|
||||
c := &Config{
|
||||
Secrets: map[string]string{
|
||||
"GITHUB_TOKEN": "github-token",
|
||||
|
|
@ -51,6 +51,96 @@ func TestConfig_GetToken(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestIsSameInstance verifies same-instance detection for token security
|
||||
func TestIsSameInstance(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
instance string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "exact match",
|
||||
url: "code.forgejo.org",
|
||||
instance: "code.forgejo.org",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "https prefix in url",
|
||||
url: "https://code.forgejo.org",
|
||||
instance: "code.forgejo.org",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "https prefix in instance",
|
||||
url: "code.forgejo.org",
|
||||
instance: "https://code.forgejo.org",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "both with https prefix",
|
||||
url: "https://code.forgejo.org",
|
||||
instance: "https://code.forgejo.org",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "trailing slash in url",
|
||||
url: "code.forgejo.org/",
|
||||
instance: "code.forgejo.org",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "trailing slash in instance",
|
||||
url: "code.forgejo.org",
|
||||
instance: "code.forgejo.org/",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "case insensitive",
|
||||
url: "Code.Forgejo.Org",
|
||||
instance: "code.forgejo.org",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "different hosts",
|
||||
url: "github.com",
|
||||
instance: "code.forgejo.org",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "http vs https same host",
|
||||
url: "http://localhost:3000",
|
||||
instance: "https://localhost:3000",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "with path in url",
|
||||
url: "https://code.forgejo.org/some/path",
|
||||
instance: "code.forgejo.org",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "empty strings",
|
||||
url: "",
|
||||
instance: "",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "whitespace handling",
|
||||
url: " code.forgejo.org ",
|
||||
instance: "code.forgejo.org",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isSameInstance(tt.url, tt.instance)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoteReusableWorkflow_CloneURL verifies URL formatting for different instances
|
||||
func TestRemoteReusableWorkflow_CloneURL(t *testing.T) {
|
||||
t.Run("adds https prefix when missing", func(t *testing.T) {
|
||||
|
|
@ -81,6 +171,42 @@ func TestRemoteReusableWorkflow_CloneURL(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestRemoteReusableWorkflow_CloneURL_TrailingSlash_NoScheme verifies trimming the trailing slash and adding https scheme when base URL has no scheme
|
||||
func TestRemoteReusableWorkflow_CloneURL_TrailingSlash_NoScheme(t *testing.T) {
|
||||
t.Run("trims trailing slash when no scheme", func(t *testing.T) {
|
||||
rw := &remoteReusableWorkflow{
|
||||
URL: "code.forgejo.org/",
|
||||
Org: "owner",
|
||||
Repo: "repo",
|
||||
}
|
||||
assert.Equal(t, "https://code.forgejo.org/owner/repo", rw.CloneURL())
|
||||
})
|
||||
}
|
||||
|
||||
// TestRemoteReusableWorkflow_CloneURL_TrailingSlash verifies trimming the trailing slash when base URL already includes http/https to avoid double slashes
|
||||
func TestRemoteReusableWorkflow_CloneURL_TrailingSlash(t *testing.T) {
|
||||
t.Run("trims trailing slash in URL", func(t *testing.T) {
|
||||
rw := &remoteReusableWorkflow{
|
||||
URL: "https://code.forgejo.org/",
|
||||
Org: "owner",
|
||||
Repo: "repo",
|
||||
}
|
||||
assert.Equal(t, "https://code.forgejo.org/owner/repo", rw.CloneURL())
|
||||
})
|
||||
}
|
||||
|
||||
// TestRemoteReusableWorkflow_CloneURL_Ports verifies that a custom port in the base URL is preserved when constructing the clone URL
|
||||
func TestRemoteReusableWorkflow_CloneURL_Ports(t *testing.T) {
|
||||
t.Run("preserves custom port", func(t *testing.T) {
|
||||
rw := &remoteReusableWorkflow{
|
||||
URL: "http://localhost:3000",
|
||||
Org: "owner",
|
||||
Repo: "repo",
|
||||
}
|
||||
assert.Equal(t, "http://localhost:3000/owner/repo", rw.CloneURL())
|
||||
})
|
||||
}
|
||||
|
||||
// TestRemoteReusableWorkflow_FilePath verifies workflow file path construction
|
||||
func TestRemoteReusableWorkflow_FilePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
|
|
|||
|
|
@ -76,17 +76,18 @@ type Config struct {
|
|||
ContainerNetworkEnableIPv6 bool // create the network with IPv6 support enabled
|
||||
}
|
||||
|
||||
// GetToken: Adapt to Gitea/Forgejo
|
||||
// GetToken returns the authentication token for git operations.
|
||||
// Priority: ACTIONS_TOKEN > GITEA_TOKEN > GITHUB_TOKEN
|
||||
// Priority: FORGEJO_ACTIONS_TOKEN > GITEA_TOKEN > GITHUB_TOKEN
|
||||
func (c Config) GetToken() string {
|
||||
token := c.Secrets["GITHUB_TOKEN"]
|
||||
if c.Secrets["GITEA_TOKEN"] != "" {
|
||||
token = c.Secrets["GITEA_TOKEN"]
|
||||
// Explicit token for Forgejo Actions (reusable workflows)
|
||||
if t := c.Secrets["FORGEJO_ACTIONS_TOKEN"]; t != "" {
|
||||
return t
|
||||
}
|
||||
if c.Secrets["ACTIONS_TOKEN"] != "" {
|
||||
token = c.Secrets["ACTIONS_TOKEN"]
|
||||
if t := c.Secrets["GITEA_TOKEN"]; t != "" {
|
||||
return t
|
||||
}
|
||||
return token
|
||||
return c.Secrets["GITHUB_TOKEN"]
|
||||
}
|
||||
|
||||
func (c *Config) GetContainerDaemonSocket() string {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue