Skip to content

Commit 37b5138

Browse files
Fix trusted publisher reusable workflow refs (#6346)
* Fix trusted publisher verification for cross-repo reusable workflows The job_workflow_ref OIDC claim contains the reusable workflow's ref, not the caller's ref/sha. Extract the ref from job_workflow_ref and job_workflow_sha instead when verifying cross-repo reusable workflows. * Fix trusted publisher token exchange for cross-repo reusable workflows Also fixes inconsistent error handling * Harden job_workflow_refs prefix validation and improve test coverage * Fixup linting
1 parent 0b62742 commit 37b5138

3 files changed

Lines changed: 159 additions & 11 deletions

File tree

app/models/oidc/trusted_publisher/github_action.rb

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,8 @@ def job_workflow_ref_condition(ref)
128128

129129
def to_access_policy(jwt)
130130
common_conditions = [repository_condition, environment_condition, repository_owner_id_condition, audience_condition].compact
131-
refs = [jwt.fetch(:ref), jwt.fetch(:sha)].compact_blank
132-
raise OIDC::AccessPolicy::AccessError, "ref and sha are both missing" if refs.empty?
131+
refs = job_workflow_refs(jwt)
132+
raise OIDC::AccessPolicy::AccessError, "no workflow refs could be extracted from the JWT claims" if refs.empty?
133133
OIDC::AccessPolicy.new(
134134
statements: refs.map do |ref|
135135
OIDC::AccessPolicy::Statement.new(
@@ -183,6 +183,31 @@ def owns_gem?(rubygem) = rubygem_trusted_publishers.exists?(rubygem: rubygem)
183183

184184
private
185185

186+
# For same-repo workflows, the JWT's ref/sha (from the caller repo) match
187+
# the workflow's ref in job_workflow_ref, so we use them directly.
188+
#
189+
# For cross-repo reusable workflows, the JWT's ref/sha belong to the caller
190+
# repo, while job_workflow_ref contains the reusable workflow's own ref.
191+
# We strip the known prefix to extract the ref, validating it matches the
192+
# loaded publisher's workflow_repository/workflow_slug.
193+
def job_workflow_refs(jwt)
194+
if workflow_repository_owner.present?
195+
refs = []
196+
if (jwf_ref = jwt[:job_workflow_ref])
197+
expected_prefix = "#{workflow_repository}/#{workflow_slug}@"
198+
unless jwf_ref.start_with?(expected_prefix)
199+
raise OIDC::AccessPolicy::AccessError,
200+
"job_workflow_ref #{jwf_ref} does not match expected prefix #{expected_prefix}"
201+
end
202+
refs << jwf_ref.delete_prefix(expected_prefix)
203+
end
204+
refs << jwt[:job_workflow_sha] if jwt[:job_workflow_sha].present?
205+
refs.compact_blank
206+
else
207+
[jwt[:ref], jwt[:sha]].compact_blank
208+
end
209+
end
210+
186211
def find_github_repository_owner_id
187212
return if repository_owner.blank?
188213
return if repository_owner_id.present?

test/integration/api/v1/oidc/trusted_publisher_controller_test.rb

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,9 @@ def jwt(claims = @claims, key: @pkey)
274274
@claims["repository"] = "cseeman/my-gem"
275275
@claims["repository_owner"] = "cseeman"
276276
@claims["repository_owner_id"] = "1946610"
277-
@claims["job_workflow_ref"] = "rubygems/shared-workflows/.github/workflows/release.yml@refs/heads/main"
277+
# Reusable workflow ref differs from caller's ref (refs/heads/main)
278+
@claims["job_workflow_ref"] = "rubygems/shared-workflows/.github/workflows/release.yml@refs/tags/v2.0"
279+
@claims["job_workflow_sha"] = "aaabbbccc111222333"
278280

279281
trusted_publisher = build(:oidc_trusted_publisher_github_action,
280282
repository_name: "my-gem",
@@ -293,6 +295,10 @@ def jwt(claims = @claims, key: @pkey)
293295
resp = response.parsed_body
294296

295297
assert_match(/^rubygems_/, resp["rubygems_api_key"])
298+
299+
api_key = trusted_publisher.api_keys.sole
300+
301+
assert_equal api_key.owner, trusted_publisher
296302
end
297303

298304
should "return not found when reusable workflow does not match configured workflow_repository" do
@@ -316,6 +322,49 @@ def jwt(claims = @claims, key: @pkey)
316322
assert_response :not_found
317323
end
318324

325+
should "succeed but only grant access to attacker's own publisher when using same reusable workflow" do
326+
# Two publishers share the same reusable workflow but are bound to different caller repos.
327+
# An attacker's JWT from their own repo must not match the victim's publisher.
328+
victim_publisher = build(:oidc_trusted_publisher_github_action,
329+
repository_name: "victim-gem",
330+
repository_owner_id: "111111",
331+
workflow_filename: "release.yml",
332+
workflow_repository_owner: "rubygems",
333+
workflow_repository_name: "shared-workflows")
334+
victim_publisher.repository_owner = "victim-org"
335+
victim_publisher.save!
336+
337+
attacker_publisher = build(:oidc_trusted_publisher_github_action,
338+
repository_name: "attacker-repo",
339+
repository_owner_id: "999999",
340+
workflow_filename: "release.yml",
341+
workflow_repository_owner: "rubygems",
342+
workflow_repository_name: "shared-workflows")
343+
attacker_publisher.repository_owner = "attacker-org"
344+
attacker_publisher.save!
345+
346+
# JWT comes from the attacker's repo using the same shared workflow
347+
@claims["repository"] = "attacker-org/attacker-repo"
348+
@claims["repository_owner"] = "attacker-org"
349+
@claims["repository_owner_id"] = "999999"
350+
@claims["job_workflow_ref"] = "rubygems/shared-workflows/.github/workflows/release.yml@refs/heads/main"
351+
@claims["job_workflow_sha"] = "aaa111bbb222"
352+
353+
post api_v1_oidc_trusted_publisher_exchange_token_path,
354+
params: { jwt: jwt.to_s }
355+
356+
# Should succeed — but only grant access to the attacker's own publisher, not the victim's
357+
assert_response :success
358+
359+
resp = response.parsed_body
360+
361+
assert_match(/^rubygems_/, resp["rubygems_api_key"])
362+
363+
# The API key must belong to the attacker's publisher, not the victim's
364+
assert_equal 1, attacker_publisher.api_keys.count
365+
assert_equal 0, victim_publisher.api_keys.count
366+
end
367+
319368
should "return not found when reusable workflow used but trusted publisher expects same-repo workflow" do
320369
@claims["repository"] = "cseeman/my-gem"
321370
@claims["repository_owner"] = "cseeman"

test/models/oidc/trusted_publisher/github_action_test.rb

Lines changed: 82 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -355,26 +355,100 @@ class OIDC::TrustedPublisher::GitHubActionTest < ActiveSupport::TestCase
355355
assert_equal "example/my-gem", publisher.workflow_repository
356356
end
357357

358-
test "#to_access_policy with reusable workflow" do
358+
test "#to_access_policy with reusable workflow uses reusable workflow refs" do
359359
publisher = create(:oidc_trusted_publisher_github_action,
360360
repository_owner: "caller-org",
361361
repository_name: "caller-repo",
362362
workflow_filename: "shared-release.yml",
363363
workflow_repository_owner: "shared-org",
364364
workflow_repository_name: "shared-workflows")
365365

366-
policy = publisher.to_access_policy({ ref: "refs/heads/main", sha: "abc123" })
366+
# Caller ref/sha differ from the reusable workflow's ref/sha
367+
policy = publisher.to_access_policy(
368+
ref: "refs/heads/main", sha: "caller-sha-111",
369+
job_workflow_ref: "shared-org/shared-workflows/.github/workflows/shared-release.yml@refs/tags/v1.0",
370+
job_workflow_sha: "reusable-sha-222"
371+
)
372+
373+
# Should create statements using the reusable workflow's ref and sha, NOT the caller's
374+
assert_equal 2, policy.statements.length
367375

368-
# Verify job_workflow_ref points to the shared workflow repo, not the caller repo
369-
first_statement = policy.statements.first
370-
job_workflow_ref_condition = first_statement.conditions.find { |c| c.claim == "job_workflow_ref" }
376+
first_condition = policy.statements.first.conditions.find { |c| c.claim == "job_workflow_ref" }
371377

372-
assert_equal "shared-org/shared-workflows/.github/workflows/shared-release.yml@refs/heads/main",
373-
job_workflow_ref_condition.value
378+
assert_equal "shared-org/shared-workflows/.github/workflows/shared-release.yml@refs/tags/v1.0",
379+
first_condition.value
380+
381+
second_condition = policy.statements.second.conditions.find { |c| c.claim == "job_workflow_ref" }
382+
383+
assert_equal "shared-org/shared-workflows/.github/workflows/shared-release.yml@reusable-sha-222",
384+
second_condition.value
374385

375386
# Verify repository condition still points to caller repo (security)
376-
repository_condition = first_statement.conditions.find { |c| c.claim == "repository" }
387+
repository_condition = policy.statements.first.conditions.find { |c| c.claim == "repository" }
377388

378389
assert_equal "caller-org/caller-repo", repository_condition.value
379390
end
391+
392+
test "#to_access_policy with reusable workflow without job_workflow_sha" do
393+
publisher = create(:oidc_trusted_publisher_github_action,
394+
repository_owner: "caller-org",
395+
repository_name: "caller-repo",
396+
workflow_filename: "shared-release.yml",
397+
workflow_repository_owner: "shared-org",
398+
workflow_repository_name: "shared-workflows")
399+
400+
policy = publisher.to_access_policy(
401+
ref: "refs/heads/main", sha: "caller-sha-111",
402+
job_workflow_ref: "shared-org/shared-workflows/.github/workflows/shared-release.yml@refs/tags/v1.0"
403+
)
404+
405+
assert_equal 1, policy.statements.length
406+
407+
condition = policy.statements.first.conditions.find { |c| c.claim == "job_workflow_ref" }
408+
409+
assert_equal "shared-org/shared-workflows/.github/workflows/shared-release.yml@refs/tags/v1.0",
410+
condition.value
411+
end
412+
413+
test "#to_access_policy same-repo workflow uses caller ref and sha" do
414+
publisher = create(:oidc_trusted_publisher_github_action,
415+
repository_owner: "example",
416+
repository_name: "my-gem",
417+
workflow_filename: "release.yml",
418+
workflow_repository_owner: nil,
419+
workflow_repository_name: nil)
420+
421+
policy = publisher.to_access_policy({ ref: "refs/heads/main", sha: "abc123" })
422+
423+
assert_equal 2, policy.statements.length
424+
425+
first_condition = policy.statements.first.conditions.find { |c| c.claim == "job_workflow_ref" }
426+
427+
assert_equal "example/my-gem/.github/workflows/release.yml@refs/heads/main",
428+
first_condition.value
429+
430+
second_condition = policy.statements.second.conditions.find { |c| c.claim == "job_workflow_ref" }
431+
432+
assert_equal "example/my-gem/.github/workflows/release.yml@abc123",
433+
second_condition.value
434+
end
435+
436+
test "#to_access_policy reusable workflow rejects mismatched prefix" do
437+
publisher = create(:oidc_trusted_publisher_github_action,
438+
repository_owner: "caller-org",
439+
repository_name: "caller-repo",
440+
workflow_filename: "shared-release.yml",
441+
workflow_repository_owner: "shared-org",
442+
workflow_repository_name: "shared-workflows")
443+
444+
# job_workflow_ref has a different workflow repo than expected
445+
assert_raises(OIDC::AccessPolicy::AccessError) do
446+
publisher.to_access_policy(
447+
ref: "refs/heads/main",
448+
sha: "caller-sha",
449+
job_workflow_ref: "attacker-org/evil-workflows/.github/workflows/shared-release.yml@refs/heads/main",
450+
job_workflow_sha: "attacker-sha-123"
451+
)
452+
end
453+
end
380454
end

0 commit comments

Comments
 (0)