From 0a8c5b982fd3ffb174a34ca4a22933cd7b8b9151 Mon Sep 17 00:00:00 2001 From: Ian Coutinho <75703845+IanCou@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:53:17 -0400 Subject: [PATCH 1/3] Accept new Gitea webhook fields so repos keep provisioning after upgrades tsoa runs with noImplicitAdditionalProperties "throw-on-extras", and the webhook bodies were modelled as the full repository object. Gitea 1.27 adds fields to it (branch_count, has_code, allow_merge_update, ...), so every repository and push delivery came back 422 and new team repos were never made public, handed to their team, or branch-protected. The webhook models now declare only the fields the handlers read and accept the rest. Nothing else in the body was trusted anyway: both handlers re-fetch the repository from the Gitea API before acting. Provisioning takes the API's copy of the repository plus the sender instead of the raw event. Tests post 1.27-shaped payloads through the generated routes (both were 422 before this change), check that a payload missing a field the handlers read is still rejected, and drive the privileged paths with faked clients: repo creation and the first push to main act on the API's copy of the repository, and do nothing when it doesn't match the body. Verified end to end against Gitea 1.27.3: a new org repo went private to public with default branch main, and its first push created the main protection rule. --- .../server/src/clients/GiteaClient/index.ts | 8 +- .../server/src/clients/GiteaClient/models.ts | 58 ++--- .../server/src/controllers/HooksController.ts | 2 +- peopleportal/server/src/routes.ts | 152 ++----------- .../test/unit/gitea-webhook-payloads.test.js | 199 ++++++++++++++++++ 5 files changed, 249 insertions(+), 170 deletions(-) create mode 100644 peopleportal/server/test/unit/gitea-webhook-payloads.test.js diff --git a/peopleportal/server/src/clients/GiteaClient/index.ts b/peopleportal/server/src/clients/GiteaClient/index.ts index f788ea0..c3341c6 100644 --- a/peopleportal/server/src/clients/GiteaClient/index.ts +++ b/peopleportal/server/src/clients/GiteaClient/index.ts @@ -23,7 +23,7 @@ import { GiteaAPITeamDefinition, GiteaAPIUserDefinition, GiteaBranchProtection, - GiteaHookRepositoryTrigger, + GiteaRepoProvisioningRequest, GiteaRepository, GiteaRepositoryBrief, } from "./models"; @@ -320,9 +320,11 @@ export class GiteaClient implements SharedResourceClient { * If requests fail, we throw the errors as-is without catching. The callee is * expected to delete the newly created repository considering standards mismatch. * - * @param repoEvent Gitea Repository Event + * @param repoEvent The repository (as fetched from the API) and its creator */ - public async handleRepoProvisioning(repoEvent: GiteaHookRepositoryTrigger) { + public async handleRepoProvisioning( + repoEvent: GiteaRepoProvisioningRequest, + ) { /* 1. Remove the creator from the collaborators list so team permissions take over */ var DeleteCollabRequestConfig: any = { ...this.GiteaBaseConfig, diff --git a/peopleportal/server/src/clients/GiteaClient/models.ts b/peopleportal/server/src/clients/GiteaClient/models.ts index 8476256..9155e69 100644 --- a/peopleportal/server/src/clients/GiteaClient/models.ts +++ b/peopleportal/server/src/clients/GiteaClient/models.ts @@ -155,43 +155,49 @@ export interface GiteaRepository extends GiteaRepositoryBrief { licenses: string[]; } -export interface GiteaHookRepositoryTrigger { - action: string; - repository: GiteaRepository; - organization: GiteaUser; - sender: GiteaUser; +/* + * Webhook bodies. Gitea adds fields to these payloads in most releases (1.27 + * added branch_count, has_code and more to `repository`), and tsoa runs with + * noImplicitAdditionalProperties "throw-on-extras", so a full model turns every + * delivery into a 400 after an upgrade. Declare only what the handlers read and + * accept the rest: the handlers re-fetch the repository from the API before + * acting on it, so nothing else in the body is trusted anyway. + */ +export interface GiteaHookUser { + id: number; + username: string; + [key: string]: unknown; } -export interface GiteaCommitUser { +export interface GiteaHookRepository { + id: number; name: string; - email: string; - username: string; + full_name: string; + owner: GiteaHookUser; + [key: string]: unknown; } -export interface GiteaCommit { - id: string; - message: string; - url: string; - author: GiteaCommitUser; - committer: GiteaCommitUser; - verification: any; - timestamp: string; - added: string[]; - removed: string[]; - modified: string[]; +export interface GiteaHookRepositoryTrigger { + action: string; + repository: GiteaHookRepository; + organization: GiteaHookUser; + sender: GiteaHookUser; + [key: string]: unknown; } export interface GiteaHookCommitTrigger { ref: string; before: string; after: string; - compare_url: string; - commits: GiteaCommit[]; - total_commits: number; - head_commit: GiteaCommit; - repository: GiteaRepository; - pusher: GiteaUser; - sender: GiteaUser; + repository: GiteaHookRepository; + [key: string]: unknown; +} + +/* What repository provisioning needs: the API's copy of the repository and the + creator to remove as a collaborator. */ +export interface GiteaRepoProvisioningRequest { + repository: GiteaRepositoryBrief; + sender: { username: string }; } export interface GiteaBranchProtection { diff --git a/peopleportal/server/src/controllers/HooksController.ts b/peopleportal/server/src/controllers/HooksController.ts index 0f1a2d1..1e79c17 100644 --- a/peopleportal/server/src/controllers/HooksController.ts +++ b/peopleportal/server/src/controllers/HooksController.ts @@ -126,8 +126,8 @@ export class HooksController extends Controller { } await this.giteaClient.handleRepoProvisioning({ - ...repoEvent, repository, + sender: repoEvent.sender, }); } catch (e: any) { /* Processing failed; leave the repository untouched for an authorized operator to review. */ diff --git a/peopleportal/server/src/routes.ts b/peopleportal/server/src/routes.ts index 4a19fa8..619a73a 100644 --- a/peopleportal/server/src/routes.ts +++ b/peopleportal/server/src/routes.ts @@ -669,157 +669,35 @@ const models: TsoaRoute.Models = { "additionalProperties": false, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "GiteaRepositoryPermissions": { - "dataType": "refObject", - "properties": { - "admin": {"dataType":"boolean","required":true}, - "push": {"dataType":"boolean","required":true}, - "pull": {"dataType":"boolean","required":true}, - }, - "additionalProperties": false, - }, - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "GiteaRepositoryInternalTracker": { - "dataType": "refObject", - "properties": { - "enable_time_tracker": {"dataType":"boolean","required":true}, - "allow_only_contributors_to_track_time": {"dataType":"boolean","required":true}, - "enable_issue_dependencies": {"dataType":"boolean","required":true}, - }, - "additionalProperties": false, - }, - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "GiteaUser": { + "GiteaHookUser": { "dataType": "refObject", "properties": { "id": {"dataType":"double","required":true}, - "login": {"dataType":"string","required":true}, - "login_name": {"dataType":"string","required":true}, - "source_id": {"dataType":"double","required":true}, - "full_name": {"dataType":"string","required":true}, - "email": {"dataType":"string","required":true}, - "avatar_url": {"dataType":"string","required":true}, - "html_url": {"dataType":"string","required":true}, - "language": {"dataType":"string","required":true}, - "is_admin": {"dataType":"boolean","required":true}, - "last_login": {"dataType":"string","required":true}, - "created": {"dataType":"string","required":true}, - "restricted": {"dataType":"boolean","required":true}, - "active": {"dataType":"boolean","required":true}, - "prohibit_login": {"dataType":"boolean","required":true}, - "location": {"dataType":"string","required":true}, - "website": {"dataType":"string","required":true}, - "description": {"dataType":"string","required":true}, - "visibility": {"dataType":"string","required":true}, - "followers_count": {"dataType":"double","required":true}, - "following_count": {"dataType":"double","required":true}, - "starred_repos_count": {"dataType":"double","required":true}, "username": {"dataType":"string","required":true}, }, - "additionalProperties": false, + "additionalProperties": {"dataType":"any"}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "GiteaRepository": { + "GiteaHookRepository": { "dataType": "refObject", "properties": { - "owner": {"ref":"GiteaUser","required":true}, - "name": {"dataType":"string","required":true}, "id": {"dataType":"double","required":true}, + "name": {"dataType":"string","required":true}, "full_name": {"dataType":"string","required":true}, - "description": {"dataType":"string","required":true}, - "empty": {"dataType":"boolean","required":true}, - "private": {"dataType":"boolean","required":true}, - "fork": {"dataType":"boolean","required":true}, - "template": {"dataType":"boolean","required":true}, - "mirror": {"dataType":"boolean","required":true}, - "size": {"dataType":"double","required":true}, - "language": {"dataType":"string","required":true}, - "languages_url": {"dataType":"string","required":true}, - "html_url": {"dataType":"string","required":true}, - "url": {"dataType":"string","required":true}, - "link": {"dataType":"string","required":true}, - "ssh_url": {"dataType":"string","required":true}, - "clone_url": {"dataType":"string","required":true}, - "original_url": {"dataType":"string","required":true}, - "website": {"dataType":"string","required":true}, - "stars_count": {"dataType":"double","required":true}, - "forks_count": {"dataType":"double","required":true}, - "watchers_count": {"dataType":"double","required":true}, - "open_issues_count": {"dataType":"double","required":true}, - "open_pr_counter": {"dataType":"double","required":true}, - "release_counter": {"dataType":"double","required":true}, - "default_branch": {"dataType":"string","required":true}, - "archived": {"dataType":"boolean","required":true}, - "created_at": {"dataType":"string","required":true}, - "updated_at": {"dataType":"string","required":true}, - "archived_at": {"dataType":"string","required":true}, - "permissions": {"ref":"GiteaRepositoryPermissions","required":true}, - "has_issues": {"dataType":"boolean","required":true}, - "internal_tracker": {"ref":"GiteaRepositoryInternalTracker","required":true}, - "has_wiki": {"dataType":"boolean","required":true}, - "has_pull_requests": {"dataType":"boolean","required":true}, - "has_projects": {"dataType":"boolean","required":true}, - "projects_mode": {"dataType":"string","required":true}, - "has_releases": {"dataType":"boolean","required":true}, - "has_packages": {"dataType":"boolean","required":true}, - "has_actions": {"dataType":"boolean","required":true}, - "ignore_whitespace_conflicts": {"dataType":"boolean","required":true}, - "allow_merge_commits": {"dataType":"boolean","required":true}, - "allow_rebase": {"dataType":"boolean","required":true}, - "allow_rebase_explicit": {"dataType":"boolean","required":true}, - "allow_squash_merge": {"dataType":"boolean","required":true}, - "allow_fast_forward_only_merge": {"dataType":"boolean","required":true}, - "allow_rebase_update": {"dataType":"boolean","required":true}, - "default_delete_branch_after_merge": {"dataType":"boolean","required":true}, - "default_merge_style": {"dataType":"string","required":true}, - "default_allow_maintainer_edit": {"dataType":"boolean","required":true}, - "avatar_url": {"dataType":"string","required":true}, - "internal": {"dataType":"boolean","required":true}, - "mirror_interval": {"dataType":"string","required":true}, - "object_format_name": {"dataType":"string","required":true}, - "mirror_updated": {"dataType":"string","required":true}, - "topics": {"dataType":"array","array":{"dataType":"string"},"required":true}, - "licenses": {"dataType":"array","array":{"dataType":"string"},"required":true}, + "owner": {"ref":"GiteaHookUser","required":true}, }, - "additionalProperties": false, + "additionalProperties": {"dataType":"any"}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "GiteaHookRepositoryTrigger": { "dataType": "refObject", "properties": { "action": {"dataType":"string","required":true}, - "repository": {"ref":"GiteaRepository","required":true}, - "organization": {"ref":"GiteaUser","required":true}, - "sender": {"ref":"GiteaUser","required":true}, - }, - "additionalProperties": false, - }, - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "GiteaCommitUser": { - "dataType": "refObject", - "properties": { - "name": {"dataType":"string","required":true}, - "email": {"dataType":"string","required":true}, - "username": {"dataType":"string","required":true}, - }, - "additionalProperties": false, - }, - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "GiteaCommit": { - "dataType": "refObject", - "properties": { - "id": {"dataType":"string","required":true}, - "message": {"dataType":"string","required":true}, - "url": {"dataType":"string","required":true}, - "author": {"ref":"GiteaCommitUser","required":true}, - "committer": {"ref":"GiteaCommitUser","required":true}, - "verification": {"dataType":"any","required":true}, - "timestamp": {"dataType":"string","required":true}, - "added": {"dataType":"array","array":{"dataType":"string"},"required":true}, - "removed": {"dataType":"array","array":{"dataType":"string"},"required":true}, - "modified": {"dataType":"array","array":{"dataType":"string"},"required":true}, + "repository": {"ref":"GiteaHookRepository","required":true}, + "organization": {"ref":"GiteaHookUser","required":true}, + "sender": {"ref":"GiteaHookUser","required":true}, }, - "additionalProperties": false, + "additionalProperties": {"dataType":"any"}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "GiteaHookCommitTrigger": { @@ -828,15 +706,9 @@ const models: TsoaRoute.Models = { "ref": {"dataType":"string","required":true}, "before": {"dataType":"string","required":true}, "after": {"dataType":"string","required":true}, - "compare_url": {"dataType":"string","required":true}, - "commits": {"dataType":"array","array":{"dataType":"refObject","ref":"GiteaCommit"},"required":true}, - "total_commits": {"dataType":"double","required":true}, - "head_commit": {"ref":"GiteaCommit","required":true}, - "repository": {"ref":"GiteaRepository","required":true}, - "pusher": {"ref":"GiteaUser","required":true}, - "sender": {"ref":"GiteaUser","required":true}, + "repository": {"ref":"GiteaHookRepository","required":true}, }, - "additionalProperties": false, + "additionalProperties": {"dataType":"any"}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "GetEventListResponse": { diff --git a/peopleportal/server/test/unit/gitea-webhook-payloads.test.js b/peopleportal/server/test/unit/gitea-webhook-payloads.test.js new file mode 100644 index 0000000..45023a3 --- /dev/null +++ b/peopleportal/server/test/unit/gitea-webhook-payloads.test.js @@ -0,0 +1,199 @@ +/** + * Gitea webhook bodies must survive new fields from a Gitea upgrade. + * + * tsoa runs with noImplicitAdditionalProperties "throw-on-extras". When the + * webhook models described the full repository object, Gitea 1.27 (which added + * branch_count, has_code and more) turned every delivery into a 422, so repos + * were never provisioned or branch-protected. These tests post payloads with + * those fields through the generated routes. The action and ref are ones the + * handlers ignore, so no Gitea or Authentik call is made. + */ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const express = require("express"); +const request = require("supertest"); +const { ValidateError } = require("tsoa"); + +const { RegisterRoutes } = require("../../dist/routes.js"); + +function buildApp() { + const app = express(); + app.use(express.json()); + RegisterRoutes(app); + /* Same mapping as app.ts's error handler. */ + app.use((err, _req, res, _next) => { + if (err instanceof ValidateError) { + return res.status(422).json({ details: err.fields }); + } + return res.status(500).end(); + }); + return app; +} + +/* Fields Gitea 1.27.3 sends that the 1.24-era model did not declare. */ +const GITEA_1_27_REPOSITORY_EXTRAS = { + branch_count: 1, + has_code: true, + allow_merge_update: true, + allow_manual_merge: false, + autodetect_manual_merge: false, + default_update_style: "merge", + mirror_last_sync_at: "0001-01-01T00:00:00Z", +}; + +const user = (username) => ({ + id: 7, + login: username, + username, + full_name: "", + email: `${username}@example.invalid`, +}); + +const repository = { + id: 42, + name: "hook-test", + full_name: "TechOps/hook-test", + owner: user("TechOps"), + private: true, + default_branch: "main", + ...GITEA_1_27_REPOSITORY_EXTRAS, +}; + +test("repository event with fields added in Gitea 1.27 is accepted", async () => { + const response = await request(buildApp()) + .post("/api/webhook/git/repoevent") + .send({ + action: "deleted", + repository, + organization: user("TechOps"), + sender: user("devadmin"), + }); + + assert.equal(response.status, 200, JSON.stringify(response.body)); +}); + +test("push event with fields added in Gitea 1.27 is accepted", async () => { + const response = await request(buildApp()) + .post("/api/webhook/git/commitevent") + .send({ + ref: "refs/heads/feature", + before: "1111111111111111111111111111111111111111", + after: "2222222222222222222222222222222222222222", + compare_url: "", + commits: [], + total_commits: 0, + head_commit: null, + repository, + pusher: user("devadmin"), + sender: user("devadmin"), + }); + + assert.equal(response.status, 200, JSON.stringify(response.body)); +}); + +/* The privileged paths, with the clients faked. HooksController builds new + clients per request and reads the classes off these module objects at call + time, so swapping the exports reaches it. */ +const giteaModule = require("../../dist/clients/GiteaClient/index.js"); +const authentikModule = require("../../dist/clients/AuthentikClient/index.js"); +const RealGiteaClient = giteaModule.GiteaClient; +const RealAuthentikClient = authentikModule.AuthentikClient; + +function withFakeClients(apiRepository, run) { + const calls = { provisioning: [], protection: [] }; + giteaModule.GiteaClient = class { + static GIT_REF_MAINBRANCH = RealGiteaClient.GIT_REF_MAINBRANCH; + static GIT_NULL_COMMITID = RealGiteaClient.GIT_NULL_COMMITID; + async getRepository() { + return apiRepository; + } + async handleRepoProvisioning(req) { + calls.provisioning.push(req); + } + async handleBranchProtectionSync(team, repositories) { + calls.protection.push({ team, repositories }); + } + }; + authentikModule.AuthentikClient = class { + getGroupPkFromName = async () => "team-pk"; + getGroupInfo = async () => ({ name: "TechOps", subteams: [] }); + }; + return run(calls).finally(() => { + giteaModule.GiteaClient = RealGiteaClient; + authentikModule.AuthentikClient = RealAuthentikClient; + }); +} + +/* What the API returns: same identity as the webhook body, but its own copy. */ +const apiRepository = { id: 42, name: "hook-test", owner: { username: "TechOps" } }; + +const createdEvent = { + action: "created", + repository, + organization: user("TechOps"), + sender: user("devadmin"), +}; + +const firstMainPush = { + ref: RealGiteaClient.GIT_REF_MAINBRANCH, + before: RealGiteaClient.GIT_NULL_COMMITID, + after: "2222222222222222222222222222222222222222", + repository, +}; + +test("repo creation provisions the API's copy of the repository, not the body's", () => + withFakeClients(apiRepository, async (calls) => { + const response = await request(buildApp()) + .post("/api/webhook/git/repoevent") + .send(createdEvent); + + assert.equal(response.status, 200, JSON.stringify(response.body)); + assert.equal(calls.provisioning.length, 1); + assert.equal(calls.provisioning[0].repository, apiRepository); + assert.equal(calls.provisioning[0].sender.username, "devadmin"); + })); + +test("repo creation does nothing when the API's repository doesn't match the body", () => + withFakeClients({ ...apiRepository, id: 99 }, async (calls) => { + const response = await request(buildApp()) + .post("/api/webhook/git/repoevent") + .send(createdEvent); + + assert.equal(response.status, 200); + assert.equal(calls.provisioning.length, 0); + })); + +test("first push to main protects the API's copy of the repository", () => + withFakeClients(apiRepository, async (calls) => { + const response = await request(buildApp()) + .post("/api/webhook/git/commitevent") + .send(firstMainPush); + + assert.equal(response.status, 200, JSON.stringify(response.body)); + assert.equal(calls.protection.length, 1); + assert.deepEqual(calls.protection[0].repositories, [apiRepository]); + })); + +test("first push to main does nothing when the API's repository doesn't match", () => + withFakeClients({ ...apiRepository, owner: { username: "Elsewhere" } }, async (calls) => { + const response = await request(buildApp()) + .post("/api/webhook/git/commitevent") + .send(firstMainPush); + + assert.equal(response.status, 200); + assert.equal(calls.protection.length, 0); + })); + +test("a payload missing a field the handlers read is still rejected", async () => { + const { id: _id, ...repositoryWithoutId } = repository; + const response = await request(buildApp()) + .post("/api/webhook/git/commitevent") + .send({ + ref: "refs/heads/main", + before: "0000000000000000000000000000000000000000", + after: "2222222222222222222222222222222222222222", + repository: repositoryWithoutId, + }); + + assert.equal(response.status, 422); +}); From cab31258390f416fccf3d337fba88138ce30846f Mon Sep 17 00:00:00 2001 From: Ian Coutinho <75703845+IanCou@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:53:17 -0400 Subject: [PATCH 2/3] Run the dev stack on Gitea 1.27.3 with production's planned hardening Bumps the local Gitea from 1.24.3 to 1.27.3, the release that fixes the Actions fork-PR approval bypasses, and applies the settings planned for git.appdevclub.com so local development sees the same behaviour: DISABLE_HTTP_GIT=false (Actions checkout needs git over HTTP), ENABLE_BASIC_AUTHENTICATION=false (no passwords over HTTP Basic) and DISABLE_QUERY_AUTH_TOKEN=true (no ?token=). With Basic passwords refused, bootstrap-gitea.sh could no longer recover a lost .gitea-token: it listed and deleted the old token with the admin password (HTTP 403). The CLI can mint tokens but not list or delete them, so recovery now mints a short-lived token and sends it as the Basic password to remove the stale people-portal-dev token (and any rotation tokens an earlier failed run left), saves the replacement, then deletes the short-lived token by name. An EXIT trap revokes the short-lived token if recovery stops partway. Tokens reach curl through --config on stdin and the Python helper through its environment, never argv. Tested: normal run; recovery with the stale token present, absent, and with an orphaned rotation token; and a recovery with the lookup's container stopped mid-run, which exits non-zero and leaves no extra token. Each success ends with exactly one valid people-portal-dev token and .gitea-token mode 600; no token appeared in any script process's arguments. Upgrade checked on an existing 1.24.3 volume: migrations 336-342 ran in one boot and scripts/verify.sh passes all 16 checks. --- peopleportal/dev-stack/docker-compose.yml | 8 +- .../dev-stack/scripts/bootstrap-gitea.sh | 82 ++++++++++++++----- 2 files changed, 70 insertions(+), 20 deletions(-) diff --git a/peopleportal/dev-stack/docker-compose.yml b/peopleportal/dev-stack/docker-compose.yml index dcaf276..98a9f07 100644 --- a/peopleportal/dev-stack/docker-compose.yml +++ b/peopleportal/dev-stack/docker-compose.yml @@ -79,7 +79,7 @@ services: start_period: 60s gitea: - image: docker.gitea.com/gitea:1.24.3 + image: docker.gitea.com/gitea:1.27.3 restart: unless-stopped environment: USER_UID: "1000" @@ -93,6 +93,12 @@ services: GITEA__server__SSH_PORT: ${GITEA_SSH_PORT:-2222} GITEA__service__DISABLE_REGISTRATION: "true" GITEA__service__ALLOW_ONLY_EXTERNAL_REGISTRATION: "true" + # Same hardening as git.appdevclub.com: git over HTTP stays on so Actions + # checkout works with the job token, but passwords are refused over HTTP + # Basic and tokens are refused in query strings. + GITEA__repository__DISABLE_HTTP_GIT: "false" + GITEA__service__ENABLE_BASIC_AUTHENTICATION: "false" + GITEA__security__DISABLE_QUERY_AUTH_TOKEN: "true" GITEA__oauth2_client__ENABLE_AUTO_REGISTRATION: "true" GITEA__webhook__ALLOWED_HOST_LIST: host.docker.internal,${PUBLIC_HOST:-localhost} GITEA__mailer__ENABLED: "true" diff --git a/peopleportal/dev-stack/scripts/bootstrap-gitea.sh b/peopleportal/dev-stack/scripts/bootstrap-gitea.sh index 7a58fe3..37a03c6 100755 --- a/peopleportal/dev-stack/scripts/bootstrap-gitea.sh +++ b/peopleportal/dev-stack/scripts/bootstrap-gitea.sh @@ -17,36 +17,80 @@ if ! awk -v user="$GITEA_ADMIN_USER" '$2 == user { found=1 } END { exit !found } --admin --must-change-password=false >&2 fi +# Tokens never go on a command line, where any local process could read them: +# curl takes them from a --config on stdin, the Python helper from its env. token_valid=false if [[ -s .gitea-token ]]; then token=$(<.gitea-token) - if curl --fail --silent --max-time 10 -H "Authorization: token $token" "$url/api/v1/user" >/dev/null; then + if printf 'header = "Authorization: token %s"\n' "$token" | + curl --fail --silent --max-time 10 --config - "$url/api/v1/user" >/dev/null; then token_valid=true fi fi -if [[ $token_valid == false ]]; then - # Run JSON parsing inside authentik's image. HTTP failures must abort rather - # than masquerade as "already exists". Only delete this stack's named token. - token_id=$(compose exec -T authentik-worker python - "$GITEA_ADMIN_USER" "$GITEA_ADMIN_PASSWORD" <<'PY' -import base64, json, sys, urllib.request -user, password = sys.argv[1:] +new_token() { + local raw + raw=$(gitea_admin user generate-access-token --username "$GITEA_ADMIN_USER" \ + --token-name "$1" --scopes all --raw) + raw=$(awk '/^[0-9a-fA-F]+$/ && length($0) == 40 {print}' <<<"$raw") + [[ $raw =~ ^[0-9a-fA-F]{40}$ ]] || die "Gitea did not return a single raw token." + printf '%s\n' "$raw" +} + +# Prints the ids of the admin's tokens named $2, or starting with $3 if given, +# except one named $4, authenticating with token $1. Gitea's token API needs +# HTTP Basic, and ENABLE_BASIC_AUTHENTICATION=false refuses passwords there, so +# the credential is a token sent as the password. JSON is parsed inside +# authentik's image; HTTP failures must abort rather than read as "none". +token_ids() { + GITEA_BASIC_SECRET=$1 compose exec -T -e GITEA_BASIC_SECRET authentik-worker \ + python - "$GITEA_ADMIN_USER" "$2" "${3:-}" "${4:-}" <<'PY' +import base64, json, os, sys, urllib.request +user, name, prefix, skip = sys.argv[1:] +secret = os.environ['GITEA_BASIC_SECRET'] request = urllib.request.Request(f'http://gitea:3000/api/v1/users/{user}/tokens') -request.add_header('Authorization', 'Basic ' + base64.b64encode(f'{user}:{password}'.encode()).decode()) +request.add_header('Authorization', 'Basic ' + base64.b64encode(f'{user}:{secret}'.encode()).decode()) with urllib.request.urlopen(request, timeout=10) as response: tokens = json.load(response) -print(next((t['id'] for t in tokens if t['name'] == 'people-portal-dev'), '')) +for t in tokens: + if t['name'] != skip and (t['name'] == name or (prefix and t['name'].startswith(prefix))): + print(t['id']) PY -) - if [[ -n $token_id ]]; then - curl --fail --silent --show-error --max-time 10 --request DELETE \ - --user "$GITEA_ADMIN_USER:$GITEA_ADMIN_PASSWORD" \ - "$url/api/v1/users/$GITEA_ADMIN_USER/tokens/$token_id" >/dev/null - fi - token=$(gitea_admin user generate-access-token --username "$GITEA_ADMIN_USER" \ - --token-name people-portal-dev --scopes all --raw) - token=$(awk '/^[0-9a-fA-F]+$/ && length($0) == 40 {print}' <<<"$token") - [[ $token =~ ^[0-9a-fA-F]{40}$ ]] || die "Gitea did not return a single raw token." +} + +# Deletes the admin's token $2 (an id or a name), authenticating with token $1. +delete_token() { + printf 'user = "%s:%s"\n' "$GITEA_ADMIN_USER" "$1" | + curl --fail --silent --show-error --max-time 10 --config - --request DELETE \ + "$url/api/v1/users/$GITEA_ADMIN_USER/tokens/$2" >/dev/null +} + +# If recovery stops partway, the short-lived token revokes itself on exit so a +# failed run doesn't leave an extra admin token behind. By name, so this needs +# only curl and Gitea, not the lookup that may be what failed. +revoke_rotate() { + [[ -n ${rotate:-} ]] || return 0 + delete_token "$rotate" "$rotate_name" || + echo "WARNING: could not revoke Gitea token $rotate_name; delete it in $GITEA_ADMIN_USER's Applications settings." >&2 +} + +if [[ $token_valid == false ]]; then + # The CLI can mint tokens but not list or delete them. A short-lived token + # clears the stale people-portal-dev one, and any rotation tokens an earlier + # failed run left, so the name can be reused. The replacement is saved before + # it deletes the short-lived token. Only this stack's names are touched. + rotate_name="people-portal-dev-rotate-$(openssl rand -hex 4)" + rotate=$(new_token "$rotate_name") + trap revoke_rotate EXIT + # Assign first: set -e ignores a failed substitution in a for list. + stale_ids=$(token_ids "$rotate" people-portal-dev people-portal-dev-rotate- "$rotate_name") + for id in $stale_ids; do + delete_token "$rotate" "$id" + done + token=$(new_token people-portal-dev) private_file "$STACK_DIR/.gitea-token" <<<"$token" + delete_token "$token" "$rotate_name" + rotate="" + trap - EXIT fi chmod 600 .gitea-token From 409a7070cafb14204c997e1285285e28afd67136 Mon Sep 17 00:00:00 2001 From: Ian Coutinho <75703845+IanCou@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:54:31 -0400 Subject: [PATCH 3/3] Add a Gitea 1.24 to 1.27 upgrade runbook for git.appdevclub.com Covers why (fork-PR approval-gate CVEs fixed in 1.26.3 and 1.27.3, with instance-wide club runners), the target config (HTTP git on so Actions checkout works, passwords off over HTTP Basic, no ?token=), backup and rollback, release-note items, People Portal's webhook fix as a prerequisite, per-org Restricted job tokens (UI only in 1.27.3), and a verification checklist. Results quoted in the page come from the dev-stack upgrade and a hardened act_runner VM. Also moves the legacy manual Gitea compose example off 1.21.0. --- .../deployment/gitea-upgrade.md | 200 ++++++++++++++++++ .../deployment/local-development.md | 2 +- 2 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 corpwiki/docs/people-portal-dev-guide/deployment/gitea-upgrade.md diff --git a/corpwiki/docs/people-portal-dev-guide/deployment/gitea-upgrade.md b/corpwiki/docs/people-portal-dev-guide/deployment/gitea-upgrade.md new file mode 100644 index 0000000..6805140 --- /dev/null +++ b/corpwiki/docs/people-portal-dev-guide/deployment/gitea-upgrade.md @@ -0,0 +1,200 @@ +--- +sidebar_position: 7 +--- + +# Gitea Upgrade (1.24 → 1.27) + +Runbook for `git.appdevclub.com`: Docker, Postgres, Authentik OIDC login, +and sign-in required to view repositories. + +Verified on 2026-10-28 by upgrading `peopleportal/dev-stack` from 1.24.3 to +1.27.3 and running Gitea Actions jobs on a hardened `act_runner` VM. + +:::warning Deploy People Portal first or together +People Portal must be running a build whose Gitea webhook models accept +unknown fields (`GiteaHookRepositoryTrigger` and friends in +`peopleportal/server/src/clients/GiteaClient/models.ts`) before or together +with the Gitea upgrade. Without it, new team repositories are never provisioned +or branch-protected. See [People Portal compatibility](#people-portal-compatibility). +::: + +## Why upgrade + +1.24.3 has fork-PR Actions approval-gate bypasses: + +| Advisory | Status | +| ------------------------------------ | ------------------------------------------------------ | +| CVE-2026-58424 / GHSA-777r-4v59-6486 | Fixed in 1.26.3 | +| CVE-2026-71184 | Fixed in 1.27.3 | +| CVE-2026-104632 | Cancel/re-run of a waiting run; listed with no fix yet | + +The club runners are **instance-wide**: any organization's workflow can reach +them. Upgrading closes the fixed bypasses; the remaining advisory still needs +tracking. + +1.26 also added configurable Actions job-token permissions +([#36173](https://github.com/go-gitea/gitea/pull/36173)). See the +[1.27 release notes](https://blog.gitea.com/release-of-1.27.0/) and +[job-token permissions documentation](https://docs.gitea.com/usage/actions/token-permissions/). + +## Target configuration + +Set these in `app.ini`: + +```ini +[repository] +DISABLE_HTTP_GIT = false + +[service] +ENABLE_BASIC_AUTHENTICATION = false +REQUIRE_SIGNIN_VIEW = true + +[security] +DISABLE_QUERY_AUTH_TOKEN = true +``` + +Or use the equivalent Docker environment variables: + +```yaml +environment: + GITEA__repository__DISABLE_HTTP_GIT: "false" + GITEA__service__ENABLE_BASIC_AUTHENTICATION: "false" + GITEA__service__REQUIRE_SIGNIN_VIEW: "true" + GITEA__security__DISABLE_QUERY_AUTH_TOKEN: "true" +``` + +Production currently has `DISABLE_HTTP_GIT = true`. That makes +`actions/checkout` fail with 403: +`Interacting with repositories by HTTP protocol is not allowed`. + +`DISABLE_HTTP_GIT` only gates Git smart-HTTP. The REST archive endpoint, +`/api/v1/repos/{owner}/{repo}/archive/*`, already serves source to tokens with +repository access, so the old setting protected little. Enabling smart-HTTP +lets checkout work while `ENABLE_BASIC_AUTHENTICATION = false` refuses account +passwords over HTTP Basic for both Git and the API. Tokens still work, including +a token sent as the Basic password. `DISABLE_QUERY_AUTH_TOKEN = true` rejects +`?token=` authentication. Keep `REQUIRE_SIGNIN_VIEW = true`. + +### Authentication results + +Expected on 1.27.3 with the settings above and a token with the required access: + +| Request | Result | +| ------------------------------------------------- | ------- | +| Git clone with account password | Refused | +| Git clone with token as Basic password | Allowed | +| Anonymous clone with `REQUIRE_SIGNIN_VIEW` | Refused | +| API HTTP Basic with account password | 401 | +| API `Authorization: token` | 200 | +| API `?token=` | 401 | +| Token-management API with token as Basic password | 200 | + +## Upgrade + +1. Deploy People Portal with the webhook-model fix, or include it in the same + maintenance window before Gitea resumes sending webhooks. +2. Stop the Gitea container. Leave Postgres running for the dump. +3. Back up the Gitea database with a Postgres dump (`pg_dump`). Back up the + complete `/data` volume too, including configuration and repositories. Keep + both backups from this stopped state. +4. Change the Gitea image to `docker.gitea.com/gitea:1.27.3`. + Skipping 1.25 and 1.26 is fine: migrations 336..342 ran in one boot locally. +5. Apply the [target configuration](#target-configuration). +6. Start Gitea, recreating the container so image and environment changes take + effect. Watch its logs for migrations and startup errors. +7. Check `https://git.appdevclub.com/api/v1/version`. It must report `1.27.3`. +8. Set [restricted token permissions](#actions-token-permissions) for each + organization and complete the [verification checklist](#verification-checklist). + +:::warning Rollback requires both backups +Migrations are one-way. To roll back, stop Gitea, restore **both** the database +dump and `/data` backup, restore the old image and configuration, then start it. +Changing the image tag alone does not undo the migration. +::: + +### First-boot Authentik message + +An `[E] Unable to register source: authentik` can appear if the IdP is not +reachable at that instant. Gitea registers the source on first use; the +`/user/oauth2/` login redirect worked afterwards. This transient message +is harmless if the later Authentik login succeeds. A persistent login failure +still needs investigation. + +## Release-note checks + +Check these changes from the 1.25–1.27 release notes against the deployment: + +| Change | Action or impact | +| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Runner registration-token endpoint changed from GET to POST in 1.26 | Update callers that register runners. People Portal does not call it. | +| Reusable workflows run server-side in 1.27 | Review existing reusable workflows. External-instance reusable-workflow URLs are rejected. | +| `[cors] X_FRAME_OPTIONS` moved to `[security]` | Move the setting if configured. | +| `[webhook] ALLOWED_HOST_LIST` has a successor, `[security] ALLOWED_HOST_LIST` | The old setting is still accepted; review the successor when updating configuration. | +| Admin hooks API returns an empty `authorization_header` | People Portal re-applies the header on every start, so its hook setup remains compatible. | +| `environment-to-ini` became `gitea config edit-ini` | Update direct invocations. The Docker image retains a compatibility wrapper. | + +## People Portal compatibility + +Gitea 1.27 adds fields to webhook `repository` objects, including `branch_count`, +`has_code`, and `allow_merge_update`. The People Portal server uses tsoa with +`noImplicitAdditionalProperties: throw-on-extras`. Without the webhook-model +fix, those payloads receive 422. + +That prevents provisioning: new team repositories are not made public, the +creator is not removed, the default branch is not set to `main`, and branch +protection is not applied. + +After the fix, the end-to-end check showed: + +- A new organization repository changed from private to public with default + branch `main`. +- The first push to `main` created the `main` protection rule with **2 required + approvals**. +- Push, merge, and approval allowlists were all `PeoplePortalTeamOwners`. +- Force push was disabled. + +## Actions token permissions + +For **each organization**, open **Settings → Actions → General → Default Token +Permissions** and select **Restricted**. + +:::warning Manual setting in 1.27.3 +This is UI-only. There is no REST endpoint or `app.ini` default in 1.27.3, so +People Portal cannot set it automatically. +::: + +With Restricted selected: + +| Job | Result | +| ------------------------------------------------------------- | ------- | +| Default job token creates a tag | 403 | +| Job declares `permissions: contents: write` and creates a tag | 201 | +| `actions/checkout` reads the repository | Allowed | + +For a job that needs tag creation, declare the mapping explicitly: + +```yaml +jobs: + release: + permissions: + contents: write + # steps follow +``` + +Fork-PR jobs are always read-only. + +## Verification checklist + +- [ ] `/api/v1/version` reports `1.27.3`. +- [ ] Authentik OIDC login through `/user/oauth2/authentik` succeeds. +- [ ] A workflow using `actions/checkout` passes on a club runner. +- [ ] Every request in the [authentication table](#authentication-results) + produces the expected result. +- [ ] Each organization's default token permissions are Restricted; default + tag creation gets 403 and explicit `contents: write` gets 201. +- [ ] Create a test repository in a team organization. Confirm it becomes + public, the creator is removed, and the default branch is `main`. +- [ ] Push to `main`. Confirm the `main` rule requires 2 approvals, all three + allowlists are `PeoplePortalTeamOwners`, and force push is disabled. +- [ ] People Portal logs contain no + `Caught Validation Error for /api/webhook/git/...` during those events. diff --git a/corpwiki/docs/people-portal-dev-guide/deployment/local-development.md b/corpwiki/docs/people-portal-dev-guide/deployment/local-development.md index 19fff5e..f124dbb 100644 --- a/corpwiki/docs/people-portal-dev-guide/deployment/local-development.md +++ b/corpwiki/docs/people-portal-dev-guide/deployment/local-development.md @@ -311,7 +311,7 @@ networks: services: server: - image: gitea/gitea:1.21.0 + image: docker.gitea.com/gitea:1.27.3 container_name: gitea environment: - USER_UID=1000