| 1 | import type { Database } from '@ghost/db'; |
| 2 | import { Inject, Injectable |
| 3 | import { ConfigService } |
| 4 | import { AuthService } |
| 5 | import type { NextFunction, |
| 6 | |
| 7 | import { DATABASE } |
| 8 | import type { Auth |
| 9 | import { |
| 10 | type LfsTokenClaims, |
| 11 | verifyLfsToken, |
| 12 | } from '../../../lib/git/lfs/lfs-token.js'; |
| 13 | import { hasScope, scopesOfKey |
| 14 | import { oauthAppBlocked } |
| 15 | import { |
| 16 | AuthenticationRequiredError, |
| 17 | RepositoryForbiddenError, |
| 18 | } from '../../../lib/repositories/access/repository-access.errors.js'; |
| 19 | import { type Actor |
| 20 | import { RepositoryNotFoundError } |
| 21 | import { RepositoryAccessService } |
| 22 | import { GitAuthenticatedBufferedRequest } |
| 23 | |
| 24 | /** Runs before the body is spooled, so a rejected push never reaches disk. */ |
| 25 | @Injectable() |
| 26 | export class GitBasicAuthMiddleware |
| 27 | private readonly secret |
| 28 | |
| 29 | constructor( |
| 30 | private readonly auth |
| 31 | private readonly access |
| 32 | @Inject(DATABASE) |
| 33 | config: ConfigService |
| 34 | ) { |
| 35 | this.secret = |
| 36 | } |
| 37 | |
| 38 | async use( |
| 39 | req: GitAuthenticatedBufferedRequest |
| 40 | res: Response |
| 41 | next: NextFunction |
| 42 | ) { |
| 43 | const { username, |
| 44 | if (!username || |
| 45 | |
| 46 | const isLfs = |
| 47 | // Git asks for the receive-pack advertisement before it pushes, so a read-only actor is turned away at `info/refs` rather than a round trip later. LFS names its operation in the batch body, which is parsed before this runs, uploads with PUT, and changes locks with POST. |
| 48 | const isPush = |
| 49 | req.path.endsWith |
| 50 | req.query.service |
| 51 | req.method === |
| 52 | (req.method === |
| 53 | (req.body as |
| 54 | |
| 55 | try { |
| 56 | const { actor, |
| 57 | await this.resolveKey |
| 58 | req.repository = |
| 59 | username, |
| 60 | repo: repo.replace |
| 61 | requesterId: actor?.userId |
| 62 | operation: isPush ? |
| 63 | }); |
| 64 | // A scoped token reads private repositories only with `repo`, and pushes with `repo` or, to a public repository, `public_repo`. Unreadable reads as missing, as on GitHub. |
| 65 | if (scopes) { |
| 66 | const isPrivate = |
| 67 | if (isPrivate && |
| 68 | throw new RepositoryNotFoundError() |
| 69 | if (isPush && |
| 70 | throw new RepositoryForbiddenError() |
| 71 | } |
| 72 | // An OAuth app the organization has not approved cannot see its repositories at all. |
| 73 | if ( |
| 74 | oauthClientId && |
| 75 | req.repository.organizationId |
| 76 | (await oauthAppBlocked(this |
| 77 | clientId: oauthClientId, |
| 78 | organizationId: req.repository |
| 79 | })) |
| 80 | ) |
| 81 | throw new RepositoryNotFoundError() |
| 82 | // A token from `git-lfs-authenticate` is good for the LFS API of one repository, and for writes only if it was asked for an upload. |
| 83 | if ( |
| 84 | scope && |
| 85 | (!isLfs || |
| 86 | scope.repositoryId !== |
| 87 | (isPush && scope |
| 88 | ) { |
| 89 | throw new AuthenticationRequiredError() |
| 90 | } |
| 91 | |
| 92 | req.actor = |
| 93 | req.apiKeyId = |
| 94 | next(); |
| 95 | } catch (error) |
| 96 | if (!(error |
| 97 | // Git only sends credentials once challenged, so this is not a dead end. |
| 98 | res.setHeader('WWW-Authenticate |
| 99 | res.status(401) |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | private async resolveKey |
| 104 | actor: Actor |
| 105 | apiKeyId: string |
| 106 | scope?: LfsTokenClaims |
| 107 | scopes: readonly |
| 108 | oauthClientId?: string |
| 109 | }> { |
| 110 | const anonymous = |
| 111 | const header = |
| 112 | if (header?.startsWith |
| 113 | const scope = |
| 114 | return scope |
| 115 | ? { |
| 116 | actor: { userId: scope |
| 117 | apiKeyId: null, |
| 118 | scope, |
| 119 | scopes: null, |
| 120 | } |
| 121 | : anonymous; |
| 122 | } |
| 123 | if (!header?.startsWith |
| 124 | |
| 125 | // Username is ignored; the password is the API key, and may contain ":". |
| 126 | const decoded = |
| 127 | const key = |
| 128 | if (!key) |
| 129 | |
| 130 | const { valid, |
| 131 | body: { key |
| 132 | }); |
| 133 | |
| 134 | // A bad key is treated as no key, so the caller is challenged again. |
| 135 | return valid && |
| 136 | ? { |
| 137 | actor: { userId: apiKey |
| 138 | apiKeyId: apiKey.id |
| 139 | scopes: scopesOfKey(apiKey |
| 140 | oauthClientId: (apiKey.metadata |
| 141 | ?.oauthClientId, |
| 142 | } |
| 143 | : anonymous; |
| 144 | } |
| 145 | } |