diff --git a/act/runner/step_action_remote.go b/act/runner/step_action_remote.go index 75624824..4f6dff1c 100644 --- a/act/runner/step_action_remote.go +++ b/act/runner/step_action_remote.go @@ -114,9 +114,18 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor { actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash()) defaultActionURL := sar.RunContext.Config.DefaultActionURL() - token := getGitCloneToken(sar.getRunContext().Config, sar.remoteAction.CloneURL(defaultActionURL)) + // For Gitea + // A composite RunContext nils Config.Secrets, so getGitCloneToken would yield an + // empty token and clone the action anonymously (401 against the authenticated + // instance). github.Token survives the composite config copy and matches the + // top-level token; keep the shouldCloneURLUseToken host gate to avoid leaking it. + cloneURL := sar.remoteAction.CloneURL(defaultActionURL) + token := "" + if shouldCloneURLUseToken(sar.RunContext.Config.GitHubInstance, cloneURL) { + token = github.Token + } gitClone := stepActionRemoteNewCloneExecutor(git.NewGitCloneExecutorInput{ - URL: sar.remoteAction.CloneURL(defaultActionURL), + URL: cloneURL, Ref: sar.remoteAction.Ref, Dir: actionDir, Token: token, diff --git a/act/runner/step_action_remote_test.go b/act/runner/step_action_remote_test.go index 56c92252..6759b78a 100644 --- a/act/runner/step_action_remote_test.go +++ b/act/runner/step_action_remote_test.go @@ -838,3 +838,83 @@ func Test_safeFilename(t *testing.T) { }) } } + +// Regression: a nested action in a composite cloned anonymously (401) because the +// composite RunContext nils Config.Secrets. The token must come from github.Token, +// which survives the config copy; the host gate must still withhold it cross-host. +func TestStepActionRemoteCloneTokenSurvivesNilSecrets(t *testing.T) { + const wantToken = "job-token" + + table := []struct { + name string + gitHubInstance string + defaultActionInstance string + wantCloneToken string + }{ + { + name: "same host forwards token despite nil secrets", + gitHubInstance: "gitea.example.com", + wantCloneToken: wantToken, + }, + { + name: "foreign host is not given the token", + gitHubInstance: "gitea.example.com", + defaultActionInstance: "github.com", + wantCloneToken: "", + }, + } + + for _, tt := range table { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + + var capturedToken string + origStepAtionRemoteNewCloneExecutor := stepActionRemoteNewCloneExecutor + stepActionRemoteNewCloneExecutor = func(input git.NewGitCloneExecutorInput) common.Executor { + capturedToken = input.Token + return func(ctx context.Context) error { return nil } + } + defer (func() { + stepActionRemoteNewCloneExecutor = origStepAtionRemoteNewCloneExecutor + })() + + sarm := &stepActionRemoteMocks{} + sar := &stepActionRemote{ + Step: &model.Step{Uses: "org/repo@v1"}, + RunContext: &RunContext{ + Config: &Config{ + GitHubInstance: tt.gitHubInstance, + DefaultActionInstance: tt.defaultActionInstance, + ActionCacheDir: "/tmp/test-cache", + // Mirrors the state of a composite RunContext: job secrets are + // stripped, but the job token is still reachable via Config.Token. + Secrets: nil, + Token: wantToken, + }, + Run: &model.Run{ + JobID: "1", + Workflow: &model.Workflow{ + Jobs: map[string]*model.Job{"1": {}}, + }, + }, + StepResults: map[string]*model.StepResult{}, + }, + readAction: sarm.readAction, + } + sar.RunContext.ExprEval = sar.RunContext.NewExpressionEvaluator(ctx) + + suffixMatcher := func(suffix string) any { + return mock.MatchedBy(func(actionDir string) bool { + return strings.HasSuffix(actionDir, suffix) + }) + } + sarm.On("readAction", sar.Step, suffixMatcher(sar.Step.UsesHash()), "", mock.Anything, mock.Anything).Return(&model.Action{}, nil) + + err := sar.prepareActionExecutor()(ctx) + require.NoError(t, err) + assert.Equal(t, tt.wantCloneToken, capturedToken) + + sarm.AssertExpectations(t) + }) + } +}