Skip to content

Commit 97a4ad1

Browse files
authored
fix: checks for some url based attacks
Disallowed path traversal and whitespaces in API URLs. Also added explicit checks in some `GitHubType` structs, primarily those that have string names which are interpolated into URLs. Added tests. --- Also included these other commits, unrelated but required to fix other issues and make the CI pass: - Bumped `actions/cache` in github workflow to v4 - Commented out two failing tests with reasons - Fixed the topics method. It was broken in nightly because of difference in behavior of vcat when a single element vector is used. made the response uniform across julia versions. - Made the JWT payload predictable for tests to pass
2 parents d24bd67 + d101a1f commit 97a4ad1

File tree

9 files changed

+77
-21
lines changed

9 files changed

+77
-21
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ jobs:
3030
with:
3131
version: ${{ matrix.version }}
3232
arch: ${{ matrix.arch }}
33-
- uses: actions/cache@v1
33+
- uses: actions/cache@v4
3434
env:
3535
cache-name: cache-artifacts
3636
with:

Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name = "GitHub"
22
uuid = "bc5e4493-9b4d-5f90-b8aa-2b2bcaad7a26"
3-
version = "5.9.0"
3+
version = "5.9.1"
44

55
[deps]
66
Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"

src/owners/owners.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ end
3333

3434
Owner(login::AbstractString, isorg = false) = Owner(Dict("login" => login, "type" => isorg ? "Organization" : "User"))
3535

36-
namefield(owner::Owner) = owner.login
36+
namefield(owner::Owner) = check_disallowed_name_pattern(owner.login)
3737

3838
typprefix(isorg) = isorg ? "orgs" : "users"
3939

src/repositories/repositories.jl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ end
3838

3939
Repo(full_name::AbstractString) = Repo(Dict("full_name" => full_name))
4040

41-
namefield(repo::Repo) = repo.full_name
41+
namefield(repo::Repo) = check_disallowed_name_pattern(repo.full_name)
4242

4343
###############
4444
# API Methods #
@@ -141,7 +141,7 @@ end
141141

142142
@api_default function topics(api::GitHubAPI, repo; options...)
143143
results, page_data = gh_get_paged_json(api, "/repos/$(name(repo))/topics"; options...)
144-
return convert(Vector{String}, results["names"]), page_data
144+
return convert(Vector{String}, results[1]["names"]), page_data
145145
end
146146

147147
@api_default function set_topics(api::GitHubAPI, repo, topics; options...)

src/repositories/secrets.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ end
1010

1111
Secret(name::AbstractString) = Secret(Dict("name" => name))
1212

13-
namefield(secret::Secret) = secret.name
13+
namefield(secret::Secret) = check_disallowed_name_pattern(secret.name)
1414

1515
##################
1616
# PublicKey Type #

src/utils/auth.jl

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,12 @@ function base64_to_base64url(string)
4343
end
4444

4545
function JWTAuth(app_id::Int, key::MbedTLS.PKContext; iat = now(Dates.UTC), exp_mins = 1)
46-
algo = base64_to_base64url(base64encode(JSON.json(Dict(
47-
"alg" => "RS256",
48-
"typ" => "JWT"
49-
))))
50-
data = base64_to_base64url(base64encode(JSON.json(Dict(
51-
"iat" => trunc(Int64, Dates.datetime2unix(iat)),
52-
"exp" => trunc(Int64, Dates.datetime2unix(iat+Dates.Minute(exp_mins))),
53-
"iss" => app_id
54-
))))
46+
algo = base64_to_base64url(base64encode("{\"typ\":\"JWT\",\"alg\":\"RS256\"}"))
47+
48+
jwt_iat = trunc(Int64, Dates.datetime2unix(iat))
49+
jwt_exp = trunc(Int64, Dates.datetime2unix(iat+Dates.Minute(exp_mins)))
50+
data = base64_to_base64url(base64encode("{\"exp\":$(jwt_exp),\"iat\":$(jwt_iat),\"iss\":$(app_id)}"))
51+
5552
signature = base64_to_base64url(base64encode(MbedTLS.sign(key, MbedTLS.MD_SHA256,
5653
MbedTLS.digest(MbedTLS.MD_SHA256, string(algo,'.',data)), RNG[])))
5754
JWTAuth(string(algo,'.',data,'.',signature))

src/utils/requests.jl

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,13 @@ end
4545
# Default API URIs #
4646
####################
4747

48-
api_uri(api::GitHubWebAPI, path) = URIs.URI(api.endpoint, path = api.endpoint.path * path)
48+
function api_uri(api::GitHubWebAPI, path)
49+
# do not allow path traversal
50+
if occursin(r"(^|/)\.\.(\/|$)", path)
51+
throw(ArgumentError("Invalid API path: '$path'"))
52+
end
53+
return URIs.URI(api.endpoint, path = api.endpoint.path * path)
54+
end
4955
api_uri(api::GitHubAPI, path) = error("URI retrieval not implemented for this API type")
5056

5157
#######################
@@ -144,7 +150,11 @@ end
144150
# for APIs which return just a list
145151
function gh_get_paged_json(api, endpoint = ""; options...)
146152
results, page_data = github_paged_get(api, endpoint; options...)
147-
return mapreduce(r -> JSON.parse(HTTP.payload(r, String)), vcat, results), page_data
153+
parsed_results = mapreduce(r -> JSON.parse(HTTP.payload(r, String)), vcat, results)
154+
if !(isa(parsed_results, Vector))
155+
parsed_results = [parsed_results]
156+
end
157+
return parsed_results, page_data
148158
end
149159

150160
# for APIs which return a Dict(key => list, "total_count" => count)
@@ -183,3 +193,21 @@ function handle_response_error(r::HTTP.Response)
183193
"\tErrors: $errors"))...)
184194
end
185195
end
196+
197+
###############
198+
# Validations #
199+
###############
200+
201+
check_disallowed_name_pattern(v) = v
202+
function check_disallowed_name_pattern(str::AbstractString)
203+
# do not allow path traversal in names
204+
if occursin(r"\.\.", str)
205+
throw(ArgumentError("name cannot contain path traversal"))
206+
end
207+
# do not allow new lines or carriage returns or any other whitespace in names
208+
if occursin(r"\s", str)
209+
throw(ArgumentError("name cannot contain line breaks"))
210+
end
211+
212+
return str
213+
end

test/ghtype_tests.jl

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1381,3 +1381,26 @@ end
13811381
@test License(license_json) == license_result
13821382
@test name(License(license_json["spdx_id"])) == name(license_result)
13831383
end
1384+
1385+
@testset "Disallowed Names" begin
1386+
testrepo = Repo(full_name="octocat/../julialang/julia")
1387+
@test_throws ArgumentError GitHub.name(testrepo)
1388+
testrepo = Repo(full_name="julialang/julia HTTP/1.1\r\nFoo: bar\r\nbaz:")
1389+
@test_throws ArgumentError GitHub.name(testrepo)
1390+
testrepo = Repo(full_name="julialang/julia")
1391+
@test GitHub.name(testrepo) == "julialang/julia"
1392+
1393+
testowner = Owner("julialang/../octocat")
1394+
@test_throws ArgumentError GitHub.name(testowner)
1395+
testowner = Owner("julialang HTTP/1.1\r\nFoo: bar\r\nbaz:")
1396+
@test_throws ArgumentError GitHub.name(testowner)
1397+
testowner = Owner("julialang")
1398+
@test GitHub.name(testowner) == "julialang"
1399+
1400+
testsecret = Secret("ABC/../octocat")
1401+
@test_throws ArgumentError GitHub.name(testsecret)
1402+
testsecret = Secret("ABC HTTP/1.1\r\nFoo: bar\r\nbaz:")
1403+
@test_throws ArgumentError GitHub.name(testsecret)
1404+
testsecret = Secret("ABC")
1405+
@test GitHub.name(testsecret) == "ABC"
1406+
end

test/read_only_api_tests.jl

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,10 @@ auth = authenticate(string(circshift(["bcc", "3fc", "03a", "33e",
5252
@test hasghobj(ghjl, first(repos(julweb; auth = auth)))
5353

5454
# test sshkey/gpgkey retrieval
55-
@test GitHub.sshkeys(testuser2; auth = auth)[1][1]["key"] == testuser2_sshkey
56-
@test startswith(GitHub.gpgkeys("JuliaTagBot"; auth = auth)[1][1]["raw_key"],
57-
"-----BEGIN PGP PUBLIC KEY BLOCK-----")
55+
# Test commented out because testuser2 seems to have been deleted or the key removed
56+
# @test GitHub.sshkeys(testuser2; auth = auth)[1][1]["key"] == testuser2_sshkey
57+
# @test startswith(GitHub.gpgkeys("JuliaTagBot"; auth = auth)[1][1]["raw_key"],
58+
# "-----BEGIN PGP PUBLIC KEY BLOCK-----")
5859

5960
# test membership queries
6061
@test GitHub.check_membership(julweb, testuser; auth = auth)
@@ -108,7 +109,8 @@ end
108109
# FIXME: for some reason, the GitHub API reports empty statuses on the GitHub.jl repo
109110
let ghjl = Repo("JuliaLang/julia"), testcommit = Commit("3200219b1f7e2681ece9e4b99bda48586fab8a93")
110111
@test status(ghjl, testcommit; auth = auth).sha == name(testcommit)
111-
@test !(isempty(first(statuses(ghjl, testcommit; auth = auth))))
112+
# The statuses API seems to be broken / not documented correctly. Ref: https://github.com/orgs/community/discussions/55455
113+
# @test !(isempty(first(statuses(ghjl, testcommit; auth = auth))))
112114
end
113115

114116
# test GitHub.comment, GitHub.comments
@@ -303,3 +305,9 @@ end
303305
topics_obj, page_data = topics("JuliaLang/julia"; auth = auth)
304306
@test length(topics_obj) > 0
305307
end
308+
309+
@testset "Prevent Path Traversal" begin
310+
@test_throws ArgumentError GitHub.api_uri(GitHub.DEFAULT_API, "/repos/foo/../bar")
311+
@test_throws ArgumentError GitHub.api_uri(GitHub.DEFAULT_API, "/repos/foo/../bar")
312+
@test string(GitHub.api_uri(GitHub.DEFAULT_API, "/repos/foo/bar")) == "https://api.github.com/repos/foo/bar"
313+
end

0 commit comments

Comments
 (0)