Skip to content

Commit f548805

Browse files
authored
Merge pull request #4 from sam-mfb/latest
Update to 1.1
2 parents 3e93860 + 26c28f3 commit f548805

16 files changed

+531
-18
lines changed

README.md

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ Run git normally and all requests for credentials should be passed through to th
4040
Notes:
4141

4242
- You can turn on more verbose debugging information by setting the environmental variable `GIT_CREDENTIAL_FORWARDER_DEBUG` to `true`
43+
- You can explicitly specify the path to your `git` executable by setting the environmental variable `GIT_CREDENTIAL_FORWARDER_GIT_PATH`. This shouldn't be necessary if `git` is in your `PATH`.
4344

4445
### Using a Dockerfile
4546

@@ -55,6 +56,7 @@ RUN unzip git-credential-forwarder.zip
5556
RUN git config --global credential.helper '!f(){ node ~/gcf-client.js $*; }; f'
5657
ENV GIT_CREDENTIAL_FORWARDER_SERVER host.docker.internal:[PORT]
5758
```
59+
5860
Of course, replace `[VERSION]` and `[PORT]` with the actual version number and port number (or use Docker's `ARG` command).
5961

6062
Note that you may need to add some other things to your git configuration. For example, to work with Azure DevOps OAuth2 authentication add:
@@ -69,7 +71,7 @@ By default the server uses a tcp server listening on `localhost`. You can tell i
6971

7072
On the client/container side, you need to bind mount the socket into your container and then run `export GIT_CREDENTIAL_FORWARDER_SERVER="/path/to/socket"`.
7173

72-
Note that this will not work from a Mac OS host per [this Docker issue](https://github.com/Docker/for-mac/issues/483), which is a signficant limitation.
74+
Note that this will not work from a Mac OS host per [this Docker issue](https://github.com/Docker/for-mac/issues/483), which is a significant limitation.
7375

7476
## Debugging
7577

@@ -83,14 +85,10 @@ Nothing is perfectly secure, but I have tried to think through the security impl
8385

8486
- OK, the above point isn't entirely correct. At least when the server is running in tcp mode (which is the default), in theory it is less secure than the `git credential fill` scenario because an attacker only needs to be running as _any user_ on your machine that has access to localhost, rather than as you (which they would need to run the direct `git credential` attack). I don't think that's a huge expansion of the threat model, at least for a developer on their own machine, but I'm interested in thinking of ways to make that harder.
8587

88+
- If you want to forward credentials between machines, DO NOT edit the server script to listen on a non-localhost interface. Instead, continue to listen on the localhost interface and use some secure means for forwarding that, e.g., `ssh`
89+
8690
- A key aspect of this utility is that the credentials will only exist in memory, i.e., the chain of `container-git <-> client-helper <-> server-helper <-> host-git` is ephemeral and will be destroyed when the processes shut down. That should limit the threat exposure to the same as when you normally run git (i.e., if someone can dump your memory they could dump your git memory as well). But it is worth thinking through places where the credentials could get accidentally stored to disk:
87-
- When you have debug mode turned on. The helper tries to sanitize debug login to avoid this, but it is still a risk.
91+
- When you have debug mode turned on. The helper sanitizes debug login to minimize this risk.
8892
- Crash dumps. I'm not aware of any way to crash this app in a way that will dump credentials, but it's a vector to keep in mind.
89-
- Other? I can't think of any other way, but let me know if you can...
90-
91-
## To Do
9293

93-
- Test more extensively. Mostly it's only be tested on Azure Devops and GitHub using git credential manager as the host credential helper.
94-
- Make the install easier.
95-
- More tests.
96-
- Maybe some utilities to make setting up the Docker part easier to do as part of a Dockerfile
94+
NB: If you believe there is a security issue with the app, please reach out to me directly via email, which is just `sam` at my company's domain.

jest.e2e.config.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module.exports = {
2+
testMatch: ["<rootDir>/(src|example)/**/*.(e2e).ts?(x)"],
3+
preset: "ts-jest",
4+
testEnvironment: "node"
5+
}

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
"main": "dist/index.js",
66
"scripts": {
77
"build": "rimraf ./dist && webpack",
8+
"build-mock-git": "rimraf ./src/__tests__/dist && webpack -c ./webpack.mock-git.js",
89
"start": "node bin/index.js",
910
"test": "jest --watch",
11+
"e2e-test": "jest -c ./jest.e2e.config.js",
1012
"lint": "eslint ./src/",
1113
"fix-formatting": "prettier --write --config ./prettier.config.js ./src/",
1214
"check-formatting": "prettier --check --config ./prettier.config.js ./src/"

src/__tests__/all.e2e.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { StdioOptions, execSync, spawn } from "child_process"
2+
import { EnvKey } from "../env"
3+
import { GitCredentialHelperOperation } from "../git-credential-types"
4+
5+
let serverProcess: ReturnType<typeof spawn>
6+
7+
const setupStdio: StdioOptions = "ignore"
8+
const PORT_FOR_TEST_SERVER = 47472
9+
const TEST_PASSWORD = "myMockSecretPassword"
10+
11+
beforeAll(() => {
12+
execSync(`pnpm build`, { stdio: setupStdio })
13+
execSync(`pnpm build-mock-git`, { stdio: setupStdio })
14+
15+
serverProcess = spawn("node", ["./dist/gcf-server.js"], {
16+
detached: true,
17+
env: {
18+
// use the mock git app on the server side
19+
[EnvKey.GIT_PATH]: "node ./src/__tests__/dist/mock-git.js",
20+
[EnvKey.PORT]: PORT_FOR_TEST_SERVER.toString(),
21+
// will be read by mock git and used in its mock return
22+
TEST_PASSWORD: TEST_PASSWORD
23+
},
24+
stdio: setupStdio
25+
})
26+
})
27+
28+
afterAll(() => {
29+
serverProcess.kill()
30+
})
31+
32+
it("Returns output of host on a valid git credential operation", async () => {
33+
// this test validates that the entire round trip is working: the client
34+
// makes a 'get' request, that is forwarded to the server, the server
35+
// passes it to `git`, gets the response, and passes it back to the
36+
// client
37+
return runClient({
38+
operation: "get",
39+
input: "username=myUser",
40+
expectations: output => {
41+
expect(output).toEqual(`username=myUser\npassword=${TEST_PASSWORD}`)
42+
}
43+
})
44+
})
45+
46+
it("Returns no output on unsupported git command", async () => {
47+
return runClient({
48+
// not an actual git command
49+
operation: "move" as GitCredentialHelperOperation,
50+
input: "username=myUser",
51+
expectations: output => {
52+
expect(output).toEqual("")
53+
}
54+
})
55+
})
56+
57+
/**
58+
* Helper function to run e2e tests
59+
*
60+
* @async
61+
* @param operation - the git credential helper operation to test
62+
* @param input - the git input string to pass
63+
* @param expectations - callback that allows running assertions on the output returned to the client
64+
*/
65+
async function runClient({
66+
operation,
67+
input,
68+
expectations
69+
}: {
70+
operation: GitCredentialHelperOperation
71+
input: string
72+
expectations: (output: string) => void
73+
}): Promise<void> {
74+
const clientProcess = spawn("node", ["./dist/gcf-client.js", operation], {
75+
env: {
76+
[EnvKey.SERVER]: "localhost:" + PORT_FOR_TEST_SERVER.toString()
77+
}
78+
})
79+
80+
let outputData = ""
81+
clientProcess.stdout.on("data", data => {
82+
outputData += data.toString()
83+
})
84+
85+
// Promise that will resolve when the client process is complete (i.e.,
86+
// when all output has been received back
87+
const done = new Promise((resolve, reject) => {
88+
clientProcess.on("close", () => {
89+
try {
90+
expectations(outputData)
91+
resolve(undefined)
92+
} catch (err) {
93+
reject(err)
94+
}
95+
})
96+
})
97+
98+
clientProcess.stdin.write(input)
99+
clientProcess.stdin.end()
100+
await done
101+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import {
2+
gitCredentialAction,
3+
gitCredentialHelperOperation
4+
} from "../git-credential-types"
5+
import {
6+
isGitCredentialHelperOperation,
7+
isGitCredentialAction,
8+
isGitCredentialInputOutput
9+
} from "../git-credential-types.guards"
10+
11+
describe(isGitCredentialHelperOperation.name, () => {
12+
it("returns true for valid operations", () => {
13+
expect(
14+
isGitCredentialHelperOperation(gitCredentialHelperOperation.GET)
15+
).toBeTruthy()
16+
expect(
17+
isGitCredentialHelperOperation(gitCredentialHelperOperation.STORE)
18+
).toBeTruthy()
19+
expect(
20+
isGitCredentialHelperOperation(gitCredentialHelperOperation.ERASE)
21+
).toBeTruthy()
22+
})
23+
24+
it("returns false for an invalid string", () => {
25+
expect(isGitCredentialHelperOperation("invalid_operation")).toBeFalsy()
26+
})
27+
28+
it("returns false for an empty string", () => {
29+
expect(isGitCredentialHelperOperation("")).toBeFalsy()
30+
})
31+
})
32+
33+
describe(isGitCredentialAction.name, () => {
34+
it("returns true for valid actions", () => {
35+
expect(isGitCredentialAction(gitCredentialAction.FILL)).toBeTruthy()
36+
expect(isGitCredentialAction(gitCredentialAction.APPROVE)).toBeTruthy()
37+
expect(isGitCredentialAction(gitCredentialAction.REJECT)).toBeTruthy()
38+
})
39+
40+
it("returns false for an invalid string", () => {
41+
expect(isGitCredentialAction("invalid_action")).toBeFalsy()
42+
})
43+
44+
it("returns false for an empty string", () => {
45+
expect(isGitCredentialAction("")).toBeFalsy()
46+
})
47+
})
48+
49+
describe(isGitCredentialInputOutput.name, () => {
50+
it("returns true for a valid GitCredentialInputOutput object", () => {
51+
const validInput = {
52+
protocol: "https",
53+
host: "example.com",
54+
path: "path/to/resource",
55+
username: "user",
56+
password: "password",
57+
password_expiry_utc: 1609459200,
58+
oauth_refresh_token: "token"
59+
}
60+
expect(isGitCredentialInputOutput(validInput)).toBeTruthy()
61+
})
62+
63+
it("returns false for null", () => {
64+
expect(isGitCredentialInputOutput(null)).toBeFalsy()
65+
})
66+
67+
it("returns false for undefined", () => {
68+
expect(isGitCredentialInputOutput(undefined)).toBeFalsy()
69+
})
70+
71+
it("returns false for non-object types", () => {
72+
expect(isGitCredentialInputOutput(123)).toBeFalsy()
73+
expect(isGitCredentialInputOutput("string")).toBeFalsy()
74+
expect(isGitCredentialInputOutput([])).toBeFalsy()
75+
})
76+
77+
it("returns true for a minimal valid GitCredentialInputOutput object", () => {
78+
const minimalValidInput = {
79+
protocol: "https",
80+
host: "example.com"
81+
}
82+
expect(isGitCredentialInputOutput(minimalValidInput)).toBeTruthy()
83+
})
84+
85+
it("returns false for objects with invalid types for properties", () => {
86+
const invalidInput = {
87+
protocol: 123, // should be string
88+
host: null // should be string
89+
}
90+
expect(isGitCredentialInputOutput(invalidInput)).toBeFalsy()
91+
})
92+
})
93+
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { gitCredentialIoApi } from "../gitcredential-io"
2+
import { Result } from "../result"
3+
4+
describe(`${gitCredentialIoApi.deserialize.name}`, () => {
5+
it("returns a successful result with correct fields from a well-formatted string", () => {
6+
const input =
7+
"protocol=https\nhost=example.com\nusername=user\npassword=pass"
8+
const expectedOutput = {
9+
protocol: "https",
10+
host: "example.com",
11+
username: "user",
12+
password: "pass"
13+
}
14+
15+
const result = gitCredentialIoApi.deserialize(input)
16+
17+
expect(result).toEqual(Result.success(expectedOutput))
18+
})
19+
20+
it("ignores malformed input", () => {
21+
const malformedInput = "protocol=https\nusernameuser\npassword=pass"
22+
const expectedObject = {
23+
protocol: "https",
24+
password: "pass"
25+
}
26+
27+
const result = gitCredentialIoApi.deserialize(malformedInput)
28+
29+
expect(result).toEqual(Result.success(expectedObject))
30+
})
31+
32+
it("ignores extra fields not defined in the GitCredentialInputOutput type", () => {
33+
const inputWithExtra =
34+
"protocol=https\nhost=example.com\nextra=field\nusername=user"
35+
const expectedOutput = {
36+
protocol: "https",
37+
host: "example.com",
38+
username: "user"
39+
}
40+
41+
const result = gitCredentialIoApi.deserialize(inputWithExtra)
42+
43+
expect(result).toEqual(Result.success(expectedOutput))
44+
})
45+
46+
it("empty input string results in empty object", () => {
47+
const emptyInput = ""
48+
const expectedOutput = {}
49+
50+
const result = gitCredentialIoApi.deserialize(emptyInput)
51+
52+
expect(result).toEqual(Result.success(expectedOutput))
53+
})
54+
})
55+
56+
describe(`${gitCredentialIoApi.serialize.name}`, () => {
57+
it("converts a GitCredentialInputOutput object to a correctly formatted string", () => {
58+
const input = {
59+
protocol: "https",
60+
host: "example.com",
61+
username: "user",
62+
password: "pass"
63+
}
64+
const expectedString =
65+
"protocol=https\nhost=example.com\nusername=user\npassword=pass"
66+
67+
const result = gitCredentialIoApi.serialize(input)
68+
69+
expect(result).toBe(expectedString)
70+
})
71+
72+
it("omits undefined fields from the output string", () => {
73+
const inputWithUndefined = {
74+
protocol: "https",
75+
host: "example.com",
76+
username: undefined
77+
}
78+
const expectedString = "protocol=https\nhost=example.com"
79+
80+
const result = gitCredentialIoApi.serialize(inputWithUndefined)
81+
82+
expect(result).toBe(expectedString)
83+
})
84+
85+
it("handles empty GitCredentialInputOutput object without errors", () => {
86+
const emptyInput = {}
87+
const expectedString = ""
88+
89+
const result = gitCredentialIoApi.serialize(emptyInput)
90+
91+
expect(result).toBe(expectedString)
92+
})
93+
})

0 commit comments

Comments
 (0)