Skip to content

Prevent falling back to default token for onprem #3997

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
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
41 changes: 41 additions & 0 deletions src/Runner.Sdk/Util/UrlUtil.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Linq;
using System.Net.Http.Headers;
using GitHub.DistributedTask.WebApi;

namespace GitHub.Runner.Sdk
{
Expand All @@ -21,6 +22,46 @@ public static bool IsHostedServer(UriBuilder gitHubUrl)
gitHubUrl.Host.EndsWith(".ghe.com", StringComparison.OrdinalIgnoreCase);
}

// For GitHub Enterprise Cloud with data residency, we allow fallback to GitHub.com for Actions resolution
public static bool IsGHECDRFallbackToDotcom(UriBuilder gitHubUrl, ActionDownloadInfo downloadInfo)
{
#if OS_WINDOWS
var downloadUrl = downloadInfo.ZipballUrl;
#else
var downloadUrl = downloadInfo.TarballUrl;
#endif
try
{
var downloadUriBuilder = new UriBuilder(downloadUrl);
if (!string.Equals(downloadUriBuilder.Host, "api.github.com", StringComparison.OrdinalIgnoreCase))
{
return false;
}

// Check if the path follows the expected pattern: /repos/{owner}/{repo}/(tar|zip)ball/{ref}
var pathSegments = downloadUriBuilder.Path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (pathSegments.Length < 5 ||
!string.Equals(pathSegments[0], "repos", StringComparison.OrdinalIgnoreCase) ||
(!string.Equals(pathSegments[3], "tarball", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(pathSegments[3], "zipball", StringComparison.OrdinalIgnoreCase)))
{
return false;
}
}
catch (UriFormatException)
{
return false;
}

if (gitHubUrl.Host.EndsWith(".ghe.localhost", StringComparison.OrdinalIgnoreCase) ||
gitHubUrl.Host.EndsWith(".ghe.com", StringComparison.OrdinalIgnoreCase))
{
return true;
}

return false;
}

public static Uri GetCredentialEmbeddedUrl(Uri baseUrl, string username, string password)
{
ArgUtil.NotNull(baseUrl, nameof(baseUrl));
Expand Down
22 changes: 20 additions & 2 deletions src/Runner.Worker/ActionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -739,13 +739,31 @@ private async Task BuildActionContainerAsync(IExecutionContext executionContext,
ArgUtil.NotNull(actionDownloadInfos.Actions, nameof(actionDownloadInfos.Actions));
var defaultAccessToken = executionContext.GetGitHubContext("token");

// Get GitHub URL for OnPrem fallback logic
UriBuilder gitHubUrl = null;
var serverUrl = executionContext.GetGitHubContext("server_url");
if (!string.IsNullOrEmpty(serverUrl))
{
gitHubUrl = new UriBuilder(serverUrl);
}
else
{
// Fallback to runner settings if GitHub context doesn't have server_url
var configurationStore = HostContext.GetService<IConfigurationStore>();
var runnerSettings = configurationStore.GetSettings();
if (!string.IsNullOrEmpty(runnerSettings.GitHubUrl))
{
gitHubUrl = new UriBuilder(runnerSettings.GitHubUrl);
}
}

foreach (var actionDownloadInfo in actionDownloadInfos.Actions.Values)
{
// Add secret
HostContext.SecretMasker.AddValue(actionDownloadInfo.Authentication?.Token);

// Default auth token
if (string.IsNullOrEmpty(actionDownloadInfo.Authentication?.Token))
// Use default auth token unless falling back from OnPrem
if (string.IsNullOrEmpty(actionDownloadInfo.Authentication?.Token) && !UrlUtil.IsGHECDRFallbackToDotcom(gitHubUrl, actionDownloadInfo))
Copy link
Preview

Copilot AI Aug 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method IsGHECDRFallbackToDotcom is called with a potentially null gitHubUrl parameter. This will cause a null reference exception if both serverUrl and runnerSettings.GitHubUrl are null or empty.

Suggested change
if (string.IsNullOrEmpty(actionDownloadInfo.Authentication?.Token) && !UrlUtil.IsGHECDRFallbackToDotcom(gitHubUrl, actionDownloadInfo))
if (string.IsNullOrEmpty(actionDownloadInfo.Authentication?.Token) && (gitHubUrl == null || !UrlUtil.IsGHECDRFallbackToDotcom(gitHubUrl, actionDownloadInfo)))

Copilot uses AI. Check for mistakes.

{
actionDownloadInfo.Authentication = new WebApi.ActionDownloadAuthentication { Token = defaultAccessToken };
}
Expand Down
82 changes: 82 additions & 0 deletions src/Test/L0/Worker/ActionManagerL0.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2547,6 +2547,88 @@ private void Setup([CallerMemberName] string name = "", bool enableComposite = t
Environment.SetEnvironmentVariable("GITHUB_ACTION_DOWNLOAD_NO_BACKOFF", "1");
}

[Theory]
[InlineData("https://company.ghe.com", "https://api.github.com/repos/{0}/tarball/{1}", false, "GHEC OnPrem fallback to dotcom - skips default token")]
[InlineData("https://ghes.company.com", "https://ghes.company.com/api/v3/repos/{0}/tarball/{1}", true, "Regular GHES - uses default token")]
[InlineData("https://company.ghe.localhost", "https://api.github.com/repos/{0}/tarball/{1}", false, "GHEC OnPrem localhost fallback to dotcom - skips default token")]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async void GetDownloadInfoAsync_DefaultTokenBehavior_BasedOnFallbackScenario(string serverUrl, string downloadUrlTemplate, bool shouldUseDefaultToken, string scenario)
{
try
{
Setup();
const string ActionName = "actions/checkout";
const string ActionRef = "v3";
var actions = new Pipelines.ActionStep()
{
Name = "action",
Id = Guid.NewGuid(),
Reference = new Pipelines.RepositoryPathReference()
{
Name = ActionName,
Ref = ActionRef,
RepositoryType = "GitHub"
}
};

_ec.Setup(x => x.GetGitHubContext("server_url")).Returns(serverUrl);
_ec.Setup(x => x.GetGitHubContext("token")).Returns("default-token");

_jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>()))
.Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) =>
{
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
foreach (var action in actions.Actions)
{
var key = $"{action.NameWithOwner}@{action.Ref}";
result.Actions[key] = new ActionDownloadInfo
{
NameWithOwner = action.NameWithOwner,
Ref = action.Ref,
ResolvedNameWithOwner = action.NameWithOwner,
ResolvedSha = $"{action.Ref}-sha",
TarballUrl = string.Format(downloadUrlTemplate, action.NameWithOwner, action.Ref),
ZipballUrl = string.Format(downloadUrlTemplate.Replace("tarball", "zipball"), action.NameWithOwner, action.Ref),
Authentication = null // No token set - will be tested for default token behavior
};
}
return Task.FromResult(result);
});

string archiveFile = await CreateRepoArchive();
using var stream = File.OpenRead(archiveFile);
var mockClientHandler = new Mock<HttpClientHandler>();
string downloadUrl = string.Format(downloadUrlTemplate, ActionName, ActionRef);
mockClientHandler.Protected().Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.Is<HttpRequestMessage>(m => m.RequestUri == new Uri(downloadUrl)), ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(stream) });

var mockHandlerFactory = new Mock<IHttpClientHandlerFactory>();
mockHandlerFactory.Setup(p => p.CreateClientHandler(It.IsAny<RunnerWebProxy>())).Returns(mockClientHandler.Object);
_hc.SetSingleton(mockHandlerFactory.Object);

await _actionManager.PrepareActionsAsync(_ec.Object, new List<Pipelines.JobStep> { actions });

var watermarkFile = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), ActionName, $"{ActionRef}.completed");
Assert.True(File.Exists(watermarkFile), $"Failed scenario: {scenario}");

if (shouldUseDefaultToken)
{
// For regular GHES, the default token should be used
_ec.Verify(x => x.GetGitHubContext("token"), Times.AtLeastOnce);
}
else
{
// For GHEC OnPrem fallback scenarios, test that the download succeeded without using default token
Assert.True(File.Exists(watermarkFile), $"GHEC OnPrem fallback scenario should succeed: {scenario}");
}
}
finally
{
Teardown();
}
}

private void Teardown()
{
_hc?.Dispose();
Expand Down
Loading