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:
Roman K. 2025-10-08 13:43:56 +02:00
parent 81e10b8ffa
commit 073b650dd0
3 changed files with 173 additions and 19 deletions

View file

@ -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)
}

View file

@ -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 {

View file

@ -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 {