fix(skill): harden GitHub token handling against credential leaks

The previous private-repo support inlined the access token into the
clone URL and then logged that URL on success — leaking the token to
log files, container stdout, and any IOException thrown when the clone
failed. The token also appeared in the process command line, visible
to anyone with shell access via `ps`.

Switch to git's GIT_CONFIG_COUNT/KEY/VALUE environment variables, which
inject `http.extraHeader: Authorization: Bearer <token>` into the child
process without ever touching argv or the repo URL. The URL stays
pristine, so the existing INFO log and error message are safe.

Other changes:
- Resolve token from `mateclaw.skill.github-token` property first, then
  fall back to GITHUB_TOKEN env var. Keeps the original deployment
  contract while letting admins manage the credential via configuration.
- Tighten the host check (prefix match on `https://github.com/` etc.)
  so a crafted URL like `https://evil.com/?u=github.com/...` cannot
  trick the fetcher into forwarding the token to a third party.
- Set GIT_TERMINAL_PROMPT=0 so a bad token fails fast instead of
  blocking on an interactive password prompt.
This commit is contained in:
matevip 2026-05-26 23:10:44 +08:00
parent 1c1409e309
commit b64a312994

View File

@ -1,6 +1,7 @@
package vip.mate.skill.installer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import vip.mate.skill.installer.model.SkillBundle;
import vip.mate.skill.runtime.SkillFrontmatterParser;
@ -27,12 +28,23 @@ public class GitSkillFetcher {
private final SkillFrontmatterParser frontmatterParser;
/** 读取环境变量 GITHUB_TOKEN用于访问私有仓库公有仓库不受影响 */
private final String githubToken = System.getenv("GITHUB_TOKEN") != null
? System.getenv("GITHUB_TOKEN") : "";
/**
* GitHub access token used when cloning private repositories.
* Resolution order: {@code mateclaw.skill.github-token} property {@code GITHUB_TOKEN}
* environment variable empty (public repos only). Kept as a plain field so the value
* is never logged or embedded in URLs it is passed to the git subprocess through
* dedicated environment variables (see {@link #cloneRepo}).
*/
private final String githubToken;
public GitSkillFetcher(SkillFrontmatterParser frontmatterParser) {
public GitSkillFetcher(
SkillFrontmatterParser frontmatterParser,
@Value("${mateclaw.skill.github-token:}") String configuredGithubToken) {
this.frontmatterParser = frontmatterParser;
String token = (configuredGithubToken != null && !configuredGithubToken.isBlank())
? configuredGithubToken
: System.getenv("GITHUB_TOKEN");
this.githubToken = (token == null) ? "" : token.trim();
}
/**
@ -102,15 +114,15 @@ public class GitSkillFetcher {
}
/**
* git clone --depth 1 到临时目录
* git clone --depth 1 to a temporary directory.
* <p>
* When a GitHub token is configured and the repository is hosted on github.com,
* the credential is forwarded to the git subprocess through {@code GIT_CONFIG_*}
* environment variables equivalent to {@code git -c http.extraHeader=...} but
* without ever placing the token in the process command line (visible to {@code ps})
* or the repository URL (visible in logs and error messages). Requires git 2.31+.
*/
private void cloneRepo(String repoUrl, String ref, Path targetDir) throws IOException, InterruptedException {
// 如果配置了 GITHUB_TOKEN 且是 GitHub 地址注入 token 以支持私有仓库
// 公有仓库带 token 访问同样正常不受影响
if (!githubToken.isBlank() && repoUrl.contains("github.com")) {
repoUrl = repoUrl.replaceFirst("https://", "https://" + githubToken + "@");
}
var command = new java.util.ArrayList<String>();
command.add("git");
command.add("clone");
@ -125,6 +137,19 @@ public class GitSkillFetcher {
ProcessBuilder pb = new ProcessBuilder(command);
pb.redirectErrorStream(true);
// Inject credentials for private GitHub repos via the git subprocess environment.
// Keeping the token out of argv and out of the URL ensures it cannot leak through
// process listings, the INFO log below, or the IOException message on clone failure.
if (!githubToken.isEmpty() && isGithubHost(repoUrl)) {
var env = pb.environment();
env.put("GIT_CONFIG_COUNT", "1");
env.put("GIT_CONFIG_KEY_0", "http.extraHeader");
env.put("GIT_CONFIG_VALUE_0", "Authorization: Bearer " + githubToken);
// Fail fast on auth errors instead of blocking on an interactive password prompt.
env.put("GIT_TERMINAL_PROMPT", "0");
}
Process process = pb.start();
boolean finished = process.waitFor(CLONE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
@ -142,6 +167,17 @@ public class GitSkillFetcher {
log.info("Cloned {} (ref={}) to {}", repoUrl, ref, targetDir);
}
/**
* Match GitHub host conservatively (scheme + host boundary) so a malicious URL like
* {@code https://evil.com/?u=github.com/...} cannot smuggle the token to a third party.
*/
private static boolean isGithubHost(String repoUrl) {
return repoUrl != null
&& (repoUrl.startsWith("https://github.com/")
|| repoUrl.startsWith("http://github.com/")
|| repoUrl.startsWith("git@github.com:"));
}
/**
* skill 根目录中定位 SKILL.md
*/