fix: mask proxy URL credentials in job logs

Basic-auth userinfo in the http-proxy input or HTTP(S)_PROXY environment
variables was never registered as a secret, so error messages carrying
the proxy URL printed the credentials unmasked in the job log.
This commit is contained in:
Tom Keller
2026-08-31 12:02:51 -07:00
parent 378a941623
commit a05dfa82bd
4 changed files with 63 additions and 1 deletions
+2 -1
View File
@@ -4,7 +4,7 @@ import { defaultProvider } from '@aws-sdk/credential-provider-node';
import type { AwsCredentialIdentity } from '@aws-sdk/types';
import { NodeHttpHandler } from '@smithy/node-http-handler';
import { ProxyAgent } from 'proxy-agent';
import { buildCustomUserAgent, errorMessage, getCallerIdentity } from './helpers';
import { buildCustomUserAgent, errorMessage, getCallerIdentity, maskProxyCredentials } from './helpers';
import { ProxyResolver } from './ProxyResolver';
if (!process.env.AWS_EXECUTION_ENV) {
@@ -32,6 +32,7 @@ export class CredentialsClient {
}
if (props.proxyServer) {
info('Configuring proxy handler for STS client');
maskProxyCredentials(props.proxyServer);
const proxyOptions: { httpProxy: string; httpsProxy: string; noProxy?: string } = {
httpProxy: props.proxyServer,
httpsProxy: props.proxyServer,
+23
View File
@@ -193,6 +193,29 @@ export function toCredentialIdentity(creds?: Partial<Credentials>): AwsCredentia
};
}
// Registers any userinfo embedded in a proxy URL as secrets so it is masked in job logs.
// First the literal proxy string, then any username/password components if parseable.
// If the username/password is percent-encoded, the decoded form is also masked.
export function maskProxyCredentials(proxyServer: string): void {
core.setSecret(proxyServer);
let url: URL;
try {
url = new URL(proxyServer);
} catch (_) {
return;
}
for (const part of [url.username, url.password]) {
if (!part) continue;
core.setSecret(part);
try {
const decoded = decodeURIComponent(part);
if (decoded !== part) core.setSecret(decoded);
} catch (_) {
// malformed percent-encoding; the raw form is already masked
}
}
}
// Tags have a more restrictive set of acceptable characters than GitHub environment variables can.
// This replaces anything not conforming to the tag restrictions by inverting the regular expression.
// See the AWS documentation for constraint specifics https://docs.aws.amazon.com/STS/latest/APIReference/API_Tag.html.
+23
View File
@@ -126,6 +126,29 @@ describe('Configure AWS Credentials helpers', {}, () => {
expect(core.exportVariable).toHaveBeenCalledWith('AWS_SESSION_TOKEN', '');
});
describe('maskProxyCredentials', {}, () => {
it('masks username and password embedded in a proxy URL', {}, () => {
helpers.maskProxyCredentials('http://user:secretpass@proxy.example.com:8080');
expect(core.setSecret).toHaveBeenCalledWith('user');
expect(core.setSecret).toHaveBeenCalledWith('secretpass');
});
it('masks both encoded and decoded forms of the credentials', {}, () => {
helpers.maskProxyCredentials('http://user:p%40ss@proxy.example.com:8080');
expect(core.setSecret).toHaveBeenCalledWith('p%40ss');
expect(core.setSecret).toHaveBeenCalledWith('p@ss');
});
it('masks the whole value even without embedded credentials or when unparseable', {}, () => {
helpers.maskProxyCredentials('http://proxy.example.com:8080');
expect(core.setSecret).toHaveBeenCalledWith('http://proxy.example.com:8080');
helpers.maskProxyCredentials('not a url');
expect(core.setSecret).toHaveBeenCalledWith('not a url');
// no username/password parts, so exactly one mask per call
expect(core.setSecret).toHaveBeenCalledTimes(2);
});
});
describe('validateAccountId', {}, () => {
it('enforces the allow-list even when the first element is empty', {}, () => {
expect(() => helpers.validateAccountId(['', '999999999999'], '111111111111')).toThrow(/does not match/);
+15
View File
@@ -1267,6 +1267,21 @@ describe('Configure AWS Credentials', {}, () => {
expect(core.setFailed).not.toHaveBeenCalled();
});
it('masks credentials embedded in the proxy URL', async () => {
vi.mocked(core.getInput).mockImplementation(
mocks.getInput({
...mocks.GH_OIDC_INPUTS,
'http-proxy': 'http://user:secretpass@proxy.example.com:8080',
}),
);
await run();
expect(core.setSecret).toHaveBeenCalledWith('user');
expect(core.setSecret).toHaveBeenCalledWith('secretpass');
expect(core.setFailed).not.toHaveBeenCalled();
});
});
describe('AWS Profile Support', {}, () => {