This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@devrunbook/integrations",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"lint": "eslint src --max-warnings=0",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
export type ForgeCapabilityName =
|
||||
| 'repositories'
|
||||
| 'repository-metadata'
|
||||
| 'branches'
|
||||
| 'tags'
|
||||
| 'releases'
|
||||
| 'contents'
|
||||
| 'branch-protection'
|
||||
| 'templates'
|
||||
| 'workflows'
|
||||
| 'topics'
|
||||
| 'languages'
|
||||
| 'permissions'
|
||||
|
||||
export type ForgeCapabilityStatus =
|
||||
'supported' | 'unsupported' | 'forbidden' | 'temporarily-unavailable'
|
||||
|
||||
export interface ForgeCapabilityState {
|
||||
readonly status: ForgeCapabilityStatus
|
||||
readonly checkedAt: string
|
||||
readonly errorCode?: ForgeSafeErrorCode
|
||||
}
|
||||
|
||||
export type ForgeSafeErrorCode =
|
||||
| 'AUTH_INVALID'
|
||||
| 'PERMISSION_MISSING'
|
||||
| 'CAPABILITY_UNSUPPORTED'
|
||||
| 'RATE_LIMITED'
|
||||
| 'NETWORK_BLOCKED'
|
||||
| 'TLS_ERROR'
|
||||
| 'REMOTE_UNAVAILABLE'
|
||||
| 'CONTENT_TOO_LARGE'
|
||||
| 'RESPONSE_INVALID'
|
||||
|
||||
export interface ForgeRepositoryRef {
|
||||
readonly owner: string
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
export interface ForgeRepository extends ForgeRepositoryRef {
|
||||
readonly id: string
|
||||
readonly fullName: string
|
||||
readonly defaultBranch: string | null
|
||||
readonly archived: boolean
|
||||
readonly private: boolean
|
||||
readonly htmlUrl: string | null
|
||||
}
|
||||
|
||||
export interface ForgeRepositoryPage {
|
||||
readonly items: readonly ForgeRepository[]
|
||||
readonly nextCursor: string | null
|
||||
}
|
||||
|
||||
export interface ForgeBranch {
|
||||
readonly name: string
|
||||
readonly commitSha: string
|
||||
readonly protected: boolean | null
|
||||
}
|
||||
|
||||
export interface ForgeTreeEntry {
|
||||
readonly path: string
|
||||
readonly kind: 'file' | 'directory' | 'other'
|
||||
readonly sha: string | null
|
||||
readonly size: number | null
|
||||
}
|
||||
|
||||
export interface ForgeFile {
|
||||
readonly path: string
|
||||
readonly sha: string | null
|
||||
readonly size: number
|
||||
readonly bytes: Uint8Array
|
||||
}
|
||||
|
||||
export interface ForgeConnectionResult {
|
||||
readonly serverVersion: string
|
||||
readonly identity: { readonly id: string; readonly login: string }
|
||||
readonly capabilities: Readonly<
|
||||
Partial<Record<ForgeCapabilityName, ForgeCapabilityState>>
|
||||
>
|
||||
}
|
||||
|
||||
export interface ForgeAdapter {
|
||||
testConnection(): Promise<ForgeConnectionResult>
|
||||
getCapabilities(
|
||||
repository: ForgeRepositoryRef,
|
||||
): Promise<Readonly<Record<ForgeCapabilityName, ForgeCapabilityState>>>
|
||||
listRepositories(
|
||||
cursor?: string | null,
|
||||
query?: string,
|
||||
): Promise<ForgeRepositoryPage>
|
||||
getRepository(repository: ForgeRepositoryRef): Promise<ForgeRepository>
|
||||
listTree(
|
||||
repository: ForgeRepositoryRef,
|
||||
ref: string,
|
||||
page?: number,
|
||||
): Promise<readonly ForgeTreeEntry[]>
|
||||
getFile(
|
||||
repository: ForgeRepositoryRef,
|
||||
ref: string,
|
||||
path: string,
|
||||
sizeLimit: number,
|
||||
): Promise<ForgeFile>
|
||||
getBranches(repository: ForgeRepositoryRef): Promise<readonly ForgeBranch[]>
|
||||
getTags(repository: ForgeRepositoryRef): Promise<readonly string[]>
|
||||
getReleases(repository: ForgeRepositoryRef): Promise<readonly string[]>
|
||||
getGovernanceEvidence(repository: ForgeRepositoryRef): Promise<unknown>
|
||||
getWorkflowEvidence(repository: ForgeRepositoryRef): Promise<unknown>
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { GiteaAdapter, GiteaClient, GiteaRequestError } from './gitea-client'
|
||||
import type { SafeHttpClient, SafeHttpResponse } from './safe-http-client'
|
||||
|
||||
const repository = {
|
||||
id: 42,
|
||||
owner: { login: 'acme' },
|
||||
name: 'widget',
|
||||
full_name: 'acme/widget',
|
||||
default_branch: 'main',
|
||||
archived: false,
|
||||
private: true,
|
||||
html_url: 'https://git.example/acme/widget',
|
||||
}
|
||||
|
||||
function json(
|
||||
status: number,
|
||||
value: unknown,
|
||||
headers: Record<string, string> = {},
|
||||
): SafeHttpResponse {
|
||||
return {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', ...headers },
|
||||
body: Buffer.from(JSON.stringify(value)),
|
||||
}
|
||||
}
|
||||
|
||||
class ScriptedHttp implements Pick<SafeHttpClient, 'get'> {
|
||||
readonly urls: string[] = []
|
||||
readonly headers: Readonly<Record<string, string>>[] = []
|
||||
|
||||
constructor(
|
||||
private readonly responder: (
|
||||
url: URL,
|
||||
call: number,
|
||||
) => SafeHttpResponse | Promise<SafeHttpResponse>,
|
||||
) {}
|
||||
|
||||
async get(url: URL, headers: Readonly<Record<string, string>> = {}) {
|
||||
this.urls.push(url.toString())
|
||||
this.headers.push(headers)
|
||||
return this.responder(url, this.urls.length)
|
||||
}
|
||||
}
|
||||
|
||||
function client(http: Pick<SafeHttpClient, 'get'>, token = 'secret-token') {
|
||||
return new GiteaClient({
|
||||
baseUrl: 'https://git.example/gitea/',
|
||||
token,
|
||||
networkPolicy: { privateNetworkPolicy: 'deny' },
|
||||
http,
|
||||
pageSize: 2,
|
||||
})
|
||||
}
|
||||
|
||||
describe('GiteaClient', () => {
|
||||
it('uses normalized literal GET endpoints and paginates with an opaque local cursor', async () => {
|
||||
const http = new ScriptedHttp((url) => {
|
||||
if (url.pathname.endsWith('/version'))
|
||||
return json(200, { version: '1.25.4' })
|
||||
if (url.pathname.endsWith('/user'))
|
||||
return json(200, { id: 7, login: 'reader' })
|
||||
return json(
|
||||
200,
|
||||
{
|
||||
data: [
|
||||
repository,
|
||||
{
|
||||
...repository,
|
||||
id: 43,
|
||||
name: 'widget-2',
|
||||
full_name: 'acme/widget-2',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ 'x-total-count': '3' },
|
||||
)
|
||||
})
|
||||
const target = client(http)
|
||||
expect(await target.getVersion()).toBe('1.25.4')
|
||||
const user = await target.getCurrentUser()
|
||||
const first = await target.listRepositories(user.id, null, 'wid get')
|
||||
expect(first.items[0]).toMatchObject({
|
||||
id: '42',
|
||||
owner: 'acme',
|
||||
name: 'widget',
|
||||
private: true,
|
||||
})
|
||||
expect(first.nextCursor).toBeTruthy()
|
||||
await target.listRepositories(user.id, first.nextCursor, 'wid get')
|
||||
|
||||
expect(http.urls[0]).toBe('https://git.example/gitea/api/v1/version')
|
||||
const search = new URL(http.urls[2]!)
|
||||
expect(search.pathname).toBe('/gitea/api/v1/repos/search')
|
||||
expect(Object.fromEntries(search.searchParams)).toMatchObject({
|
||||
uid: '7',
|
||||
private: 'true',
|
||||
exclusive: 'false',
|
||||
sort: 'alpha',
|
||||
order: 'asc',
|
||||
page: '1',
|
||||
limit: '2',
|
||||
q: 'wid get',
|
||||
})
|
||||
expect(new URL(http.urls[3]!).searchParams.get('page')).toBe('2')
|
||||
expect(http.headers[0]!.authorization).toBe('token secret-token')
|
||||
expect(http.headers[1]!.authorization).toBe('token secret-token')
|
||||
expect(JSON.stringify(target)).not.toContain('secret-token')
|
||||
})
|
||||
|
||||
it('rejects cursor substitution and unsafe file paths before transport', async () => {
|
||||
const http = new ScriptedHttp(() => json(200, { data: [] }))
|
||||
const target = client(http)
|
||||
const cursor = Buffer.from(
|
||||
JSON.stringify({ page: 2, query: 'one' }),
|
||||
).toString('base64url')
|
||||
await expect(target.listRepositories('7', cursor, 'two')).rejects.toThrow(
|
||||
'cursor is invalid',
|
||||
)
|
||||
await expect(
|
||||
target.getFile({ owner: 'acme', name: 'widget' }, 'main', '../.env', 10),
|
||||
).rejects.toThrow('file path is invalid')
|
||||
expect(http.urls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('decodes bounded file content and rejects size inconsistencies', async () => {
|
||||
const http = new ScriptedHttp((_url, call) =>
|
||||
call === 1
|
||||
? json(200, {
|
||||
path: 'README.md',
|
||||
sha: 'abc',
|
||||
size: 5,
|
||||
encoding: 'base64',
|
||||
content: 'aGVsbG8=',
|
||||
})
|
||||
: json(200, {
|
||||
path: 'README.md',
|
||||
sha: 'abc',
|
||||
size: 2,
|
||||
encoding: 'base64',
|
||||
content: 'aGVsbG8=',
|
||||
}),
|
||||
)
|
||||
const target = client(http)
|
||||
const file = await target.getFile(
|
||||
{ owner: 'acme', name: 'widget' },
|
||||
'deadbeef',
|
||||
'README.md',
|
||||
5,
|
||||
)
|
||||
expect(Buffer.from(file.bytes).toString()).toBe('hello')
|
||||
await expect(
|
||||
target.getFile(
|
||||
{ owner: 'acme', name: 'widget' },
|
||||
'deadbeef',
|
||||
'README.md',
|
||||
5,
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'RESPONSE_INVALID' })
|
||||
expect(http.urls[0]).toContain('/contents/README.md?ref=deadbeef')
|
||||
})
|
||||
|
||||
it('treats Gitea null tree pages after the first page as pagination exhaustion', async () => {
|
||||
const http = new ScriptedHttp(() =>
|
||||
json(200, { sha: 'deadbeef', tree: null, truncated: false }),
|
||||
)
|
||||
await expect(
|
||||
client(http).listTree({ owner: 'acme', name: 'widget' }, 'main', 2),
|
||||
).resolves.toEqual([])
|
||||
await expect(
|
||||
client(http).listTree({ owner: 'acme', name: 'widget' }, 'main', 1),
|
||||
).rejects.toMatchObject({ code: 'RESPONSE_INVALID' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
[401, 'AUTH_INVALID'],
|
||||
[403, 'PERMISSION_MISSING'],
|
||||
[404, 'CAPABILITY_UNSUPPORTED'],
|
||||
[429, 'RATE_LIMITED'],
|
||||
[503, 'REMOTE_UNAVAILABLE'],
|
||||
] as const)(
|
||||
'maps status %s to %s without upstream body disclosure',
|
||||
async (status, code) => {
|
||||
const http = new ScriptedHttp(() =>
|
||||
json(status, { message: 'token secret-token internal stack' }),
|
||||
)
|
||||
const error = await client(http)
|
||||
.getCurrentUser()
|
||||
.catch((caught: unknown) => caught)
|
||||
expect(error).toBeInstanceOf(GiteaRequestError)
|
||||
expect(error).toMatchObject({ code, status })
|
||||
expect((error as Error).message).not.toContain('secret-token')
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('GiteaAdapter capability degradation', () => {
|
||||
it('records optional failures independently while retaining supported capabilities', async () => {
|
||||
const http = new ScriptedHttp((url) => {
|
||||
const path = url.pathname
|
||||
if (path.endsWith('/version')) return json(200, { version: '1.25.4' })
|
||||
if (path.endsWith('/user')) return json(200, { id: 7, login: 'reader' })
|
||||
if (path.endsWith('/repos/search'))
|
||||
return json(200, { data: [repository] })
|
||||
if (path.endsWith('/branch_protections')) return json(404, {})
|
||||
if (path.endsWith('/actions/workflows')) return json(403, {})
|
||||
if (path.includes('/releases')) return json(429, {})
|
||||
if (path.endsWith('/branches'))
|
||||
return json(200, [
|
||||
{ name: 'main', commit: { id: 'abc' }, protected: true },
|
||||
])
|
||||
if (path.endsWith('/tags')) return json(200, [{ name: 'v1.0.0' }])
|
||||
if (path.endsWith('/topics')) return json(200, { topics: ['typescript'] })
|
||||
if (path.endsWith('/languages')) return json(200, { TypeScript: 10 })
|
||||
return json(200, repository)
|
||||
})
|
||||
const adapter = new GiteaAdapter(
|
||||
client(http),
|
||||
() => new Date('2026-07-27T12:00:00Z'),
|
||||
)
|
||||
const connection = await adapter.testConnection()
|
||||
expect(connection).toMatchObject({
|
||||
serverVersion: '1.25.4',
|
||||
identity: { id: '7', login: 'reader' },
|
||||
})
|
||||
const capabilities = await adapter.getCapabilities({
|
||||
owner: 'acme',
|
||||
name: 'widget',
|
||||
})
|
||||
expect(capabilities['repository-metadata'].status).toBe('supported')
|
||||
expect(capabilities['branch-protection']).toMatchObject({
|
||||
status: 'unsupported',
|
||||
errorCode: 'CAPABILITY_UNSUPPORTED',
|
||||
})
|
||||
expect(capabilities.workflows).toMatchObject({
|
||||
status: 'forbidden',
|
||||
errorCode: 'PERMISSION_MISSING',
|
||||
})
|
||||
expect(capabilities.releases).toMatchObject({
|
||||
status: 'temporarily-unavailable',
|
||||
errorCode: 'RATE_LIMITED',
|
||||
})
|
||||
expect(capabilities.contents.status).toBe('supported')
|
||||
expect(
|
||||
http.urls.every((value) =>
|
||||
new URL(value).pathname.startsWith('/gitea/api/v1/'),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,643 @@
|
||||
import {
|
||||
type ForgeAdapter,
|
||||
type ForgeBranch,
|
||||
type ForgeCapabilityName,
|
||||
type ForgeCapabilityState,
|
||||
type ForgeConnectionResult,
|
||||
type ForgeFile,
|
||||
type ForgeRepository,
|
||||
type ForgeRepositoryPage,
|
||||
type ForgeRepositoryRef,
|
||||
type ForgeSafeErrorCode,
|
||||
type ForgeTreeEntry,
|
||||
} from './forge-adapter'
|
||||
import {
|
||||
normalizeGiteaBaseUrl,
|
||||
type GiteaNetworkPolicy,
|
||||
NetworkPolicyError,
|
||||
} from './network-policy'
|
||||
import {
|
||||
SafeHttpClient,
|
||||
SafeHttpError,
|
||||
type SafeHttpResponse,
|
||||
} from './safe-http-client'
|
||||
|
||||
export class GiteaRequestError extends Error {
|
||||
constructor(
|
||||
readonly code: ForgeSafeErrorCode,
|
||||
readonly status: number | null,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'GiteaRequestError'
|
||||
}
|
||||
}
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
function object(value: unknown, context: string): JsonObject {
|
||||
if (value === null || Array.isArray(value) || typeof value !== 'object') {
|
||||
throw new GiteaRequestError(
|
||||
'RESPONSE_INVALID',
|
||||
null,
|
||||
`${context} was not an object`,
|
||||
)
|
||||
}
|
||||
return value as JsonObject
|
||||
}
|
||||
|
||||
function string(value: unknown, context: string): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new GiteaRequestError(
|
||||
'RESPONSE_INVALID',
|
||||
null,
|
||||
`${context} was not a string`,
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function number(value: unknown, context: string): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new GiteaRequestError(
|
||||
'RESPONSE_INVALID',
|
||||
null,
|
||||
`${context} was not a number`,
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function parseJson(response: SafeHttpResponse): unknown {
|
||||
const contentType = response.headers['content-type']?.toLowerCase() ?? ''
|
||||
if (!contentType.includes('application/json')) {
|
||||
throw new GiteaRequestError(
|
||||
'RESPONSE_INVALID',
|
||||
response.status,
|
||||
'Gitea response was not JSON',
|
||||
)
|
||||
}
|
||||
try {
|
||||
return JSON.parse(Buffer.from(response.body).toString('utf8')) as unknown
|
||||
} catch {
|
||||
throw new GiteaRequestError(
|
||||
'RESPONSE_INVALID',
|
||||
response.status,
|
||||
'Gitea returned invalid JSON',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function statusError(response: SafeHttpResponse): GiteaRequestError {
|
||||
const code: ForgeSafeErrorCode =
|
||||
response.status === 401
|
||||
? 'AUTH_INVALID'
|
||||
: response.status === 403
|
||||
? 'PERMISSION_MISSING'
|
||||
: response.status === 404
|
||||
? 'CAPABILITY_UNSUPPORTED'
|
||||
: response.status === 429
|
||||
? 'RATE_LIMITED'
|
||||
: response.status >= 500
|
||||
? 'REMOTE_UNAVAILABLE'
|
||||
: 'RESPONSE_INVALID'
|
||||
return new GiteaRequestError(
|
||||
code,
|
||||
response.status,
|
||||
`Gitea request failed with status ${response.status}`,
|
||||
)
|
||||
}
|
||||
|
||||
function mappedError(error: unknown): GiteaRequestError {
|
||||
if (error instanceof GiteaRequestError) return error
|
||||
if (error instanceof NetworkPolicyError)
|
||||
return new GiteaRequestError('NETWORK_BLOCKED', null, error.message)
|
||||
if (error instanceof SafeHttpError)
|
||||
return new GiteaRequestError(error.code, null, error.message)
|
||||
return new GiteaRequestError(
|
||||
'REMOTE_UNAVAILABLE',
|
||||
null,
|
||||
'Gitea request failed',
|
||||
)
|
||||
}
|
||||
|
||||
function repositoryFrom(value: unknown): ForgeRepository {
|
||||
const item = object(value, 'Gitea repository')
|
||||
const owner = object(item.owner, 'Gitea repository owner')
|
||||
return {
|
||||
id: String(number(item.id, 'Gitea repository id')),
|
||||
owner: string(
|
||||
owner.login ?? owner.username,
|
||||
'Gitea repository owner login',
|
||||
),
|
||||
name: string(item.name, 'Gitea repository name'),
|
||||
fullName: string(item.full_name, 'Gitea repository full name'),
|
||||
defaultBranch:
|
||||
typeof item.default_branch === 'string' ? item.default_branch : null,
|
||||
archived: item.archived === true,
|
||||
private: item.private === true,
|
||||
htmlUrl: typeof item.html_url === 'string' ? item.html_url : null,
|
||||
}
|
||||
}
|
||||
|
||||
function encodeSegment(value: string, label: string): string {
|
||||
if (
|
||||
value.length === 0 ||
|
||||
value.length > 255 ||
|
||||
[...value].some((character) => {
|
||||
const codePoint = character.codePointAt(0)!
|
||||
return codePoint <= 0x1f || codePoint === 0x7f
|
||||
})
|
||||
) {
|
||||
throw new TypeError(`${label} is invalid`)
|
||||
}
|
||||
return encodeURIComponent(value)
|
||||
}
|
||||
|
||||
function encodeFilePath(path: string): string {
|
||||
if (path.startsWith('/') || path.includes('\\'))
|
||||
throw new TypeError('Gitea file path is invalid')
|
||||
const segments = path.split('/')
|
||||
if (
|
||||
segments.some(
|
||||
(segment) => segment === '' || segment === '.' || segment === '..',
|
||||
)
|
||||
) {
|
||||
throw new TypeError('Gitea file path is invalid')
|
||||
}
|
||||
return segments
|
||||
.map((segment) => encodeSegment(segment, 'Gitea file path'))
|
||||
.join('/')
|
||||
}
|
||||
|
||||
interface RepositoryCursor {
|
||||
readonly page: number
|
||||
readonly query: string
|
||||
}
|
||||
|
||||
function decodeCursor(
|
||||
cursor: string | null | undefined,
|
||||
query: string,
|
||||
): RepositoryCursor {
|
||||
if (!cursor) return { page: 1, query }
|
||||
try {
|
||||
const parsed = object(
|
||||
JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')),
|
||||
'Repository cursor',
|
||||
)
|
||||
if (
|
||||
!Number.isInteger(parsed.page) ||
|
||||
(parsed.page as number) < 2 ||
|
||||
parsed.query !== query
|
||||
)
|
||||
throw new Error()
|
||||
return { page: parsed.page as number, query }
|
||||
} catch {
|
||||
throw new TypeError('Repository cursor is invalid')
|
||||
}
|
||||
}
|
||||
|
||||
function encodeCursor(page: number, query: string): string {
|
||||
return Buffer.from(JSON.stringify({ page, query }), 'utf8').toString(
|
||||
'base64url',
|
||||
)
|
||||
}
|
||||
|
||||
export interface GiteaClientOptions {
|
||||
readonly baseUrl: string
|
||||
readonly token: string
|
||||
readonly networkPolicy: GiteaNetworkPolicy
|
||||
readonly http?: Pick<SafeHttpClient, 'get'>
|
||||
readonly pageSize?: number
|
||||
}
|
||||
|
||||
export class GiteaClient {
|
||||
readonly #apiBase: string
|
||||
readonly #authorization: string
|
||||
readonly #http: Pick<SafeHttpClient, 'get'>
|
||||
readonly #pageSize: number
|
||||
|
||||
constructor(options: GiteaClientOptions) {
|
||||
if (options.token.length === 0)
|
||||
throw new TypeError('Gitea token cannot be empty')
|
||||
this.#apiBase = `${normalizeGiteaBaseUrl(options.baseUrl, options.networkPolicy)}/api/v1`
|
||||
this.#authorization = `token ${options.token}`
|
||||
this.#http =
|
||||
options.http ?? new SafeHttpClient({ policy: options.networkPolicy })
|
||||
this.#pageSize = options.pageSize ?? 50
|
||||
if (
|
||||
!Number.isInteger(this.#pageSize) ||
|
||||
this.#pageSize < 1 ||
|
||||
this.#pageSize > 100
|
||||
) {
|
||||
throw new RangeError('Gitea page size must be between 1 and 100')
|
||||
}
|
||||
}
|
||||
|
||||
async #get(
|
||||
path: string,
|
||||
authenticated = true,
|
||||
): Promise<{ json: unknown; response: SafeHttpResponse }> {
|
||||
try {
|
||||
const response = await this.#http.get(
|
||||
new URL(`${this.#apiBase}${path}`),
|
||||
authenticated
|
||||
? { authorization: this.#authorization, accept: 'application/json' }
|
||||
: { accept: 'application/json' },
|
||||
)
|
||||
if (response.status < 200 || response.status >= 300)
|
||||
throw statusError(response)
|
||||
return { json: parseJson(response), response }
|
||||
} catch (error) {
|
||||
throw mappedError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async getVersion(): Promise<string> {
|
||||
const { json } = await this.#get('/version')
|
||||
return string(
|
||||
object(json, 'Gitea version response').version,
|
||||
'Gitea version',
|
||||
)
|
||||
}
|
||||
|
||||
async getCurrentUser(): Promise<{
|
||||
readonly id: string
|
||||
readonly login: string
|
||||
}> {
|
||||
const { json } = await this.#get('/user')
|
||||
const user = object(json, 'Gitea user')
|
||||
return {
|
||||
id: String(number(user.id, 'Gitea user id')),
|
||||
login: string(user.login, 'Gitea user login'),
|
||||
}
|
||||
}
|
||||
|
||||
async listRepositories(
|
||||
userId: string,
|
||||
cursor?: string | null,
|
||||
query = '',
|
||||
): Promise<ForgeRepositoryPage> {
|
||||
const state = decodeCursor(cursor, query)
|
||||
const parameters = new URLSearchParams({
|
||||
uid: userId,
|
||||
private: 'true',
|
||||
exclusive: 'false',
|
||||
sort: 'alpha',
|
||||
order: 'asc',
|
||||
page: String(state.page),
|
||||
limit: String(this.#pageSize),
|
||||
})
|
||||
if (query) parameters.set('q', query)
|
||||
const { json, response } = await this.#get(`/repos/search?${parameters}`)
|
||||
const root = object(json, 'Gitea repository search')
|
||||
const data = Array.isArray(root.data)
|
||||
? root.data
|
||||
: Array.isArray(json)
|
||||
? json
|
||||
: null
|
||||
if (!data)
|
||||
throw new GiteaRequestError(
|
||||
'RESPONSE_INVALID',
|
||||
response.status,
|
||||
'Gitea repository page was invalid',
|
||||
)
|
||||
const totalHeader = response.headers['x-total-count']
|
||||
const total = totalHeader === undefined ? null : Number(totalHeader)
|
||||
const hasNext = Number.isFinite(total)
|
||||
? state.page * this.#pageSize < total!
|
||||
: data.length === this.#pageSize
|
||||
return {
|
||||
items: data.map(repositoryFrom),
|
||||
nextCursor: hasNext ? encodeCursor(state.page + 1, query) : null,
|
||||
}
|
||||
}
|
||||
|
||||
async getRepository(ref: ForgeRepositoryRef): Promise<ForgeRepository> {
|
||||
const { json } = await this.#get(this.#repositoryPath(ref))
|
||||
return repositoryFrom(json)
|
||||
}
|
||||
|
||||
#repositoryPath(ref: ForgeRepositoryRef): string {
|
||||
return `/repos/${encodeSegment(ref.owner, 'Gitea owner')}/${encodeSegment(ref.name, 'Gitea repository')}`
|
||||
}
|
||||
|
||||
async listBranches(ref: ForgeRepositoryRef): Promise<readonly ForgeBranch[]> {
|
||||
const { json } = await this.#get(
|
||||
`${this.#repositoryPath(ref)}/branches?limit=${this.#pageSize}&page=1`,
|
||||
)
|
||||
if (!Array.isArray(json))
|
||||
throw new GiteaRequestError(
|
||||
'RESPONSE_INVALID',
|
||||
null,
|
||||
'Gitea branches response was invalid',
|
||||
)
|
||||
return json.map((entry) => {
|
||||
const branch = object(entry, 'Gitea branch')
|
||||
const commit = object(branch.commit, 'Gitea branch commit')
|
||||
return {
|
||||
name: string(branch.name, 'Gitea branch name'),
|
||||
commitSha: string(commit.id, 'Gitea branch commit id'),
|
||||
protected:
|
||||
typeof branch.protected === 'boolean' ? branch.protected : null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async getBranch(
|
||||
ref: ForgeRepositoryRef,
|
||||
branch: string,
|
||||
): Promise<ForgeBranch> {
|
||||
const { json } = await this.#get(
|
||||
`${this.#repositoryPath(ref)}/branches/${encodeSegment(branch, 'Gitea branch')}`,
|
||||
)
|
||||
const value = object(json, 'Gitea branch')
|
||||
const commit = object(value.commit, 'Gitea branch commit')
|
||||
return {
|
||||
name: string(value.name, 'Gitea branch name'),
|
||||
commitSha: string(commit.id, 'Gitea branch commit id'),
|
||||
protected: typeof value.protected === 'boolean' ? value.protected : null,
|
||||
}
|
||||
}
|
||||
|
||||
async listTree(
|
||||
ref: ForgeRepositoryRef,
|
||||
sha: string,
|
||||
page = 1,
|
||||
): Promise<readonly ForgeTreeEntry[]> {
|
||||
if (!Number.isInteger(page) || page < 1)
|
||||
throw new RangeError('Gitea tree page is invalid')
|
||||
const { json } = await this.#get(
|
||||
`${this.#repositoryPath(ref)}/git/trees/${encodeSegment(sha, 'Gitea tree ref')}?recursive=false&page=${page}&per_page=${this.#pageSize}`,
|
||||
)
|
||||
const tree = object(json, 'Gitea tree')
|
||||
if (page > 1 && tree.tree === null) return []
|
||||
if (!Array.isArray(tree.tree))
|
||||
throw new GiteaRequestError(
|
||||
'RESPONSE_INVALID',
|
||||
null,
|
||||
'Gitea tree entries were invalid',
|
||||
)
|
||||
return tree.tree.map((entry) => {
|
||||
const value = object(entry, 'Gitea tree entry')
|
||||
return {
|
||||
path: string(value.path, 'Gitea tree path'),
|
||||
kind:
|
||||
value.type === 'blob'
|
||||
? 'file'
|
||||
: value.type === 'tree'
|
||||
? 'directory'
|
||||
: 'other',
|
||||
sha: typeof value.sha === 'string' ? value.sha : null,
|
||||
size: typeof value.size === 'number' ? value.size : null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async getFile(
|
||||
ref: ForgeRepositoryRef,
|
||||
revision: string,
|
||||
path: string,
|
||||
sizeLimit: number,
|
||||
): Promise<ForgeFile> {
|
||||
if (!Number.isInteger(sizeLimit) || sizeLimit < 1)
|
||||
throw new RangeError('Gitea file size limit is invalid')
|
||||
const { json } = await this.#get(
|
||||
`${this.#repositoryPath(ref)}/contents/${encodeFilePath(path)}?ref=${encodeURIComponent(revision)}`,
|
||||
)
|
||||
const value = object(json, 'Gitea file')
|
||||
const reportedSize = number(value.size, 'Gitea file size')
|
||||
if (reportedSize > sizeLimit)
|
||||
throw new GiteaRequestError(
|
||||
'CONTENT_TOO_LARGE',
|
||||
200,
|
||||
'Gitea file exceeded the configured byte limit',
|
||||
)
|
||||
const encoding = string(value.encoding, 'Gitea file encoding')
|
||||
if (encoding !== 'base64')
|
||||
throw new GiteaRequestError(
|
||||
'RESPONSE_INVALID',
|
||||
200,
|
||||
'Gitea file encoding was unsupported',
|
||||
)
|
||||
const bytes = Buffer.from(
|
||||
string(value.content, 'Gitea file content').replace(/\s/gu, ''),
|
||||
'base64',
|
||||
)
|
||||
if (bytes.byteLength !== reportedSize || bytes.byteLength > sizeLimit) {
|
||||
throw new GiteaRequestError(
|
||||
bytes.byteLength > sizeLimit ? 'CONTENT_TOO_LARGE' : 'RESPONSE_INVALID',
|
||||
200,
|
||||
'Gitea file size did not match its payload',
|
||||
)
|
||||
}
|
||||
return {
|
||||
path,
|
||||
sha: typeof value.sha === 'string' ? value.sha : null,
|
||||
size: bytes.byteLength,
|
||||
bytes,
|
||||
}
|
||||
}
|
||||
|
||||
async listTags(ref: ForgeRepositoryRef): Promise<readonly string[]> {
|
||||
return this.#names(
|
||||
`${this.#repositoryPath(ref)}/tags?limit=${this.#pageSize}&page=1`,
|
||||
)
|
||||
}
|
||||
|
||||
async listReleases(ref: ForgeRepositoryRef): Promise<readonly string[]> {
|
||||
return this.#names(
|
||||
`${this.#repositoryPath(ref)}/releases?limit=${this.#pageSize}&page=1`,
|
||||
)
|
||||
}
|
||||
|
||||
async #names(path: string): Promise<readonly string[]> {
|
||||
const { json } = await this.#get(path)
|
||||
if (!Array.isArray(json))
|
||||
throw new GiteaRequestError(
|
||||
'RESPONSE_INVALID',
|
||||
null,
|
||||
'Gitea list response was invalid',
|
||||
)
|
||||
return json.map((item) =>
|
||||
string(
|
||||
object(item, 'Gitea list item').name ??
|
||||
object(item, 'Gitea list item').tag_name,
|
||||
'Gitea item name',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async getBranchProtections(ref: ForgeRepositoryRef): Promise<unknown> {
|
||||
return (await this.#get(`${this.#repositoryPath(ref)}/branch_protections`))
|
||||
.json
|
||||
}
|
||||
|
||||
async getRootContents(ref: ForgeRepositoryRef): Promise<unknown> {
|
||||
return (await this.#get(`${this.#repositoryPath(ref)}/contents`)).json
|
||||
}
|
||||
|
||||
async getTopics(ref: ForgeRepositoryRef): Promise<unknown> {
|
||||
return (
|
||||
await this.#get(
|
||||
`${this.#repositoryPath(ref)}/topics?page=1&limit=${this.#pageSize}`,
|
||||
)
|
||||
).json
|
||||
}
|
||||
|
||||
async getLanguages(ref: ForgeRepositoryRef): Promise<unknown> {
|
||||
return (await this.#get(`${this.#repositoryPath(ref)}/languages`)).json
|
||||
}
|
||||
|
||||
async getWorkflows(ref: ForgeRepositoryRef): Promise<unknown> {
|
||||
return (await this.#get(`${this.#repositoryPath(ref)}/actions/workflows`))
|
||||
.json
|
||||
}
|
||||
|
||||
async getRepositoryPermission(
|
||||
ref: ForgeRepositoryRef,
|
||||
login: string,
|
||||
): Promise<unknown> {
|
||||
return (
|
||||
await this.#get(
|
||||
`${this.#repositoryPath(ref)}/collaborators/${encodeSegment(login, 'Gitea login')}/permission`,
|
||||
)
|
||||
).json
|
||||
}
|
||||
}
|
||||
|
||||
const capabilityNames: readonly ForgeCapabilityName[] = [
|
||||
'repositories',
|
||||
'repository-metadata',
|
||||
'branches',
|
||||
'tags',
|
||||
'releases',
|
||||
'contents',
|
||||
'branch-protection',
|
||||
'templates',
|
||||
'workflows',
|
||||
'topics',
|
||||
'languages',
|
||||
'permissions',
|
||||
]
|
||||
|
||||
function capabilityFromError(
|
||||
error: unknown,
|
||||
checkedAt: string,
|
||||
): ForgeCapabilityState {
|
||||
const mapped = mappedError(error)
|
||||
return {
|
||||
status:
|
||||
mapped.code === 'PERMISSION_MISSING' || mapped.code === 'AUTH_INVALID'
|
||||
? 'forbidden'
|
||||
: mapped.code === 'CAPABILITY_UNSUPPORTED'
|
||||
? 'unsupported'
|
||||
: 'temporarily-unavailable',
|
||||
checkedAt,
|
||||
errorCode: mapped.code,
|
||||
}
|
||||
}
|
||||
|
||||
export class GiteaAdapter implements ForgeAdapter {
|
||||
readonly #client: GiteaClient
|
||||
readonly #now: () => Date
|
||||
#identity: { readonly id: string; readonly login: string } | null = null
|
||||
|
||||
constructor(client: GiteaClient, now: () => Date = () => new Date()) {
|
||||
this.#client = client
|
||||
this.#now = now
|
||||
}
|
||||
|
||||
async testConnection(): Promise<ForgeConnectionResult> {
|
||||
const [serverVersion, identity] = await Promise.all([
|
||||
this.#client.getVersion(),
|
||||
this.#client.getCurrentUser(),
|
||||
])
|
||||
this.#identity = identity
|
||||
await this.#client.listRepositories(identity.id)
|
||||
const checkedAt = this.#now().toISOString()
|
||||
return {
|
||||
serverVersion,
|
||||
identity,
|
||||
capabilities: { repositories: { status: 'supported', checkedAt } },
|
||||
}
|
||||
}
|
||||
|
||||
async listRepositories(
|
||||
cursor?: string | null,
|
||||
query = '',
|
||||
): Promise<ForgeRepositoryPage> {
|
||||
const identity = this.#identity ?? (await this.#client.getCurrentUser())
|
||||
this.#identity = identity
|
||||
return this.#client.listRepositories(identity.id, cursor, query)
|
||||
}
|
||||
|
||||
getRepository(ref: ForgeRepositoryRef) {
|
||||
return this.#client.getRepository(ref)
|
||||
}
|
||||
listTree(ref: ForgeRepositoryRef, revision: string, page = 1) {
|
||||
return this.#client.listTree(ref, revision, page)
|
||||
}
|
||||
getFile(
|
||||
ref: ForgeRepositoryRef,
|
||||
revision: string,
|
||||
path: string,
|
||||
sizeLimit: number,
|
||||
) {
|
||||
return this.#client.getFile(ref, revision, path, sizeLimit)
|
||||
}
|
||||
getBranches(ref: ForgeRepositoryRef) {
|
||||
return this.#client.listBranches(ref)
|
||||
}
|
||||
getTags(ref: ForgeRepositoryRef) {
|
||||
return this.#client.listTags(ref)
|
||||
}
|
||||
getReleases(ref: ForgeRepositoryRef) {
|
||||
return this.#client.listReleases(ref)
|
||||
}
|
||||
getGovernanceEvidence(ref: ForgeRepositoryRef) {
|
||||
return this.#client.getBranchProtections(ref)
|
||||
}
|
||||
getWorkflowEvidence(ref: ForgeRepositoryRef) {
|
||||
return this.#client.getWorkflows(ref)
|
||||
}
|
||||
|
||||
async getCapabilities(
|
||||
ref: ForgeRepositoryRef,
|
||||
): Promise<Readonly<Record<ForgeCapabilityName, ForgeCapabilityState>>> {
|
||||
const checkedAt = this.#now().toISOString()
|
||||
const probes: Readonly<
|
||||
Partial<Record<ForgeCapabilityName, () => Promise<unknown>>>
|
||||
> = {
|
||||
repositories: () => this.listRepositories(),
|
||||
'repository-metadata': () => this.#client.getRepository(ref),
|
||||
branches: () => this.#client.listBranches(ref),
|
||||
tags: () => this.#client.listTags(ref),
|
||||
releases: () => this.#client.listReleases(ref),
|
||||
contents: () => this.#client.getRootContents(ref),
|
||||
templates: () => this.#client.getRootContents(ref),
|
||||
'branch-protection': () => this.#client.getBranchProtections(ref),
|
||||
workflows: () => this.#client.getWorkflows(ref),
|
||||
topics: () => this.#client.getTopics(ref),
|
||||
languages: () => this.#client.getLanguages(ref),
|
||||
permissions: async () =>
|
||||
this.#client.getRepositoryPermission(
|
||||
ref,
|
||||
(this.#identity ?? (await this.#client.getCurrentUser())).login,
|
||||
),
|
||||
}
|
||||
const entries = await Promise.all(
|
||||
capabilityNames.map(async (name) => {
|
||||
try {
|
||||
await probes[name]!()
|
||||
return [name, { status: 'supported', checkedAt }] as const
|
||||
} catch (error) {
|
||||
return [name, capabilityFromError(error, checkedAt)] as const
|
||||
}
|
||||
}),
|
||||
)
|
||||
return Object.fromEntries(entries) as unknown as Readonly<
|
||||
Record<ForgeCapabilityName, ForgeCapabilityState>
|
||||
>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './forge-adapter'
|
||||
export * from './gitea-client'
|
||||
export * from './network-policy'
|
||||
export * from './safe-http-client'
|
||||
export * from './secret-envelope'
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
assertResolvedAddressesAllowed,
|
||||
classifyAddress,
|
||||
normalizeGiteaBaseUrl,
|
||||
type GiteaNetworkPolicy,
|
||||
} from './network-policy'
|
||||
|
||||
const denied: GiteaNetworkPolicy = { privateNetworkPolicy: 'deny' }
|
||||
|
||||
describe('Gitea network policy', () => {
|
||||
it.each([
|
||||
['127.0.0.1', 'loopback'],
|
||||
['169.254.169.254', 'metadata'],
|
||||
['10.1.2.3', 'private'],
|
||||
['192.168.1.2', 'private'],
|
||||
['::1', 'loopback'],
|
||||
['fe80::1', 'link-local'],
|
||||
['fd00::1', 'private'],
|
||||
['::ffff:127.0.0.1', 'loopback'],
|
||||
['2001:db8::1', 'reserved'],
|
||||
['8.8.8.8', 'public'],
|
||||
['2606:4700:4700::1111', 'public'],
|
||||
] as const)('classifies %s as %s', (address, expected) => {
|
||||
expect(classifyAddress(address)).toBe(expected)
|
||||
})
|
||||
|
||||
it('rejects one blocked answer in a mixed DNS response', () => {
|
||||
expect(() =>
|
||||
assertResolvedAddressesAllowed(
|
||||
'gitea.example',
|
||||
[
|
||||
{ address: '8.8.8.8', family: 4 },
|
||||
{ address: '127.0.0.1', family: 4 },
|
||||
],
|
||||
denied,
|
||||
),
|
||||
).toThrow('blocked loopback')
|
||||
})
|
||||
|
||||
it('allows only private addresses for an explicitly allowed private hostname', () => {
|
||||
const policy: GiteaNetworkPolicy = {
|
||||
privateNetworkPolicy: 'allow-explicit-hosts',
|
||||
allowedHosts: ['Git.Internal.'],
|
||||
}
|
||||
expect(() =>
|
||||
assertResolvedAddressesAllowed(
|
||||
'git.internal',
|
||||
[{ address: '172.20.1.4', family: 4 }],
|
||||
policy,
|
||||
),
|
||||
).not.toThrow()
|
||||
expect(() =>
|
||||
assertResolvedAddressesAllowed(
|
||||
'other.internal',
|
||||
[{ address: '172.20.1.4', family: 4 }],
|
||||
policy,
|
||||
),
|
||||
).toThrow('blocked private')
|
||||
expect(() =>
|
||||
assertResolvedAddressesAllowed(
|
||||
'git.internal',
|
||||
[{ address: '169.254.169.254', family: 4 }],
|
||||
policy,
|
||||
),
|
||||
).toThrow('blocked metadata')
|
||||
})
|
||||
|
||||
it('normalizes subpath installations and rejects unsafe URL fields', () => {
|
||||
expect(normalizeGiteaBaseUrl('https://GIT.EXAMPLE/gitea///', denied)).toBe(
|
||||
'https://git.example/gitea',
|
||||
)
|
||||
for (const value of [
|
||||
'ftp://git.example',
|
||||
'https://user:pass@git.example',
|
||||
'https://git.example?token=secret',
|
||||
'https://git.example/#fragment',
|
||||
]) {
|
||||
expect(() => normalizeGiteaBaseUrl(value, denied)).toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('requires both operator policy and per-connection opt-in for HTTP', () => {
|
||||
const base = {
|
||||
privateNetworkPolicy: 'allow-explicit-hosts' as const,
|
||||
allowedHosts: ['git.internal'],
|
||||
}
|
||||
expect(() => normalizeGiteaBaseUrl('http://git.internal', base)).toThrow()
|
||||
expect(
|
||||
normalizeGiteaBaseUrl('http://git.internal', {
|
||||
...base,
|
||||
allowInsecureHttp: true,
|
||||
}),
|
||||
).toBe('http://git.internal')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
import { isIP } from 'node:net'
|
||||
|
||||
export type PrivateNetworkPolicy = 'deny' | 'allow-explicit-hosts'
|
||||
|
||||
export interface GiteaNetworkPolicy {
|
||||
readonly privateNetworkPolicy: PrivateNetworkPolicy
|
||||
readonly allowedHosts?: readonly string[]
|
||||
readonly allowInsecureHttp?: boolean
|
||||
readonly requestTimeoutMs?: number
|
||||
readonly maxResponseBytes?: number
|
||||
readonly maxRedirects?: number
|
||||
}
|
||||
|
||||
export type AddressClass =
|
||||
| 'public'
|
||||
| 'private'
|
||||
| 'loopback'
|
||||
| 'link-local'
|
||||
| 'metadata'
|
||||
| 'unspecified'
|
||||
| 'multicast'
|
||||
| 'reserved'
|
||||
|
||||
export interface ResolvedAddress {
|
||||
readonly address: string
|
||||
readonly family: 4 | 6
|
||||
}
|
||||
|
||||
export class NetworkPolicyError extends Error {
|
||||
readonly code = 'NETWORK_BLOCKED' as const
|
||||
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'NetworkPolicyError'
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedHostname(hostname: string): string {
|
||||
return hostname
|
||||
.replace(/^\[|\]$/gu, '')
|
||||
.toLowerCase()
|
||||
.replace(/\.$/u, '')
|
||||
}
|
||||
|
||||
function parseIpv4(value: string): readonly number[] | null {
|
||||
const pieces = value.split('.')
|
||||
if (pieces.length !== 4) return null
|
||||
const bytes = pieces.map((piece) => {
|
||||
if (!/^(0|[1-9]\d{0,2})$/u.test(piece)) return -1
|
||||
const parsed = Number(piece)
|
||||
return parsed <= 255 ? parsed : -1
|
||||
})
|
||||
return bytes.some((byte) => byte < 0) ? null : bytes
|
||||
}
|
||||
|
||||
function ipv6Words(value: string): readonly number[] {
|
||||
const withoutZone = value.split('%', 1)[0]!.toLowerCase()
|
||||
const mappedIndex = withoutZone.lastIndexOf(':')
|
||||
let input = withoutZone
|
||||
if (withoutZone.includes('.')) {
|
||||
const ipv4 = parseIpv4(withoutZone.slice(mappedIndex + 1))
|
||||
if (!ipv4) throw new NetworkPolicyError('Invalid IPv6 address')
|
||||
input = `${withoutZone.slice(0, mappedIndex)}:${((ipv4[0]! << 8) | ipv4[1]!).toString(16)}:${((ipv4[2]! << 8) | ipv4[3]!).toString(16)}`
|
||||
}
|
||||
const halves = input.split('::')
|
||||
if (halves.length > 2) throw new NetworkPolicyError('Invalid IPv6 address')
|
||||
const left = halves[0] ? halves[0].split(':') : []
|
||||
const right = halves[1] ? halves[1].split(':') : []
|
||||
const omitted = 8 - left.length - right.length
|
||||
if ((halves.length === 1 && omitted !== 0) || omitted < 0) {
|
||||
throw new NetworkPolicyError('Invalid IPv6 address')
|
||||
}
|
||||
return [...left, ...Array.from({ length: omitted }, () => '0'), ...right].map(
|
||||
(word) => Number.parseInt(word, 16),
|
||||
)
|
||||
}
|
||||
|
||||
export function classifyAddress(address: string): AddressClass {
|
||||
const family = isIP(address)
|
||||
if (family === 4) {
|
||||
const [a, b, c, d] = parseIpv4(address)!
|
||||
if (a === 169 && b === 254 && c === 169 && d === 254) return 'metadata'
|
||||
if (a === 0) return 'unspecified'
|
||||
if (a === 127) return 'loopback'
|
||||
if (a === 169 && b === 254) return 'link-local'
|
||||
if (
|
||||
a === 10 ||
|
||||
(a === 172 && b! >= 16 && b! <= 31) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 100 && b! >= 64 && b! <= 127)
|
||||
) {
|
||||
return 'private'
|
||||
}
|
||||
if (a! >= 224 && a! <= 239) return 'multicast'
|
||||
if (
|
||||
a! >= 240 ||
|
||||
(a === 192 && b === 0 && c === 0) ||
|
||||
(a === 192 && b === 0 && c === 2) ||
|
||||
(a === 198 && b === 51 && c === 100) ||
|
||||
(a === 203 && b === 0 && c === 113) ||
|
||||
(a === 198 && (b === 18 || b === 19))
|
||||
) {
|
||||
return 'reserved'
|
||||
}
|
||||
return 'public'
|
||||
}
|
||||
if (family === 6) {
|
||||
const words = ipv6Words(address)
|
||||
if (words.every((word) => word === 0)) return 'unspecified'
|
||||
if (words.slice(0, 7).every((word) => word === 0) && words[7] === 1) {
|
||||
return 'loopback'
|
||||
}
|
||||
if (words.slice(0, 5).every((word) => word === 0) && words[5] === 0xffff) {
|
||||
const mapped = `${words[6]! >> 8}.${words[6]! & 255}.${words[7]! >> 8}.${words[7]! & 255}`
|
||||
return classifyAddress(mapped)
|
||||
}
|
||||
if ((words[0]! & 0xfe00) === 0xfc00) return 'private'
|
||||
if ((words[0]! & 0xffc0) === 0xfe80) return 'link-local'
|
||||
if ((words[0]! & 0xff00) === 0xff00) return 'multicast'
|
||||
if (words[0] === 0x2001 && words[1] === 0x0db8) return 'reserved'
|
||||
return 'public'
|
||||
}
|
||||
throw new NetworkPolicyError('Resolved address is not a valid IP address')
|
||||
}
|
||||
|
||||
export function normalizeGiteaBaseUrl(
|
||||
input: string,
|
||||
policy: GiteaNetworkPolicy,
|
||||
): string {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(input)
|
||||
} catch {
|
||||
throw new NetworkPolicyError('Gitea base URL must be absolute')
|
||||
}
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
throw new NetworkPolicyError('Gitea base URL must use HTTPS')
|
||||
}
|
||||
if (url.username || url.password || url.search || url.hash) {
|
||||
throw new NetworkPolicyError(
|
||||
'Gitea base URL cannot contain userinfo, query parameters or a fragment',
|
||||
)
|
||||
}
|
||||
const host = normalizedHostname(url.hostname)
|
||||
const explicitlyAllowed = (policy.allowedHosts ?? []).some(
|
||||
(allowed) => normalizedHostname(allowed) === host,
|
||||
)
|
||||
if (
|
||||
url.protocol === 'http:' &&
|
||||
(!policy.allowInsecureHttp ||
|
||||
policy.privateNetworkPolicy !== 'allow-explicit-hosts' ||
|
||||
!explicitlyAllowed)
|
||||
) {
|
||||
throw new NetworkPolicyError(
|
||||
'Private HTTP requires request opt-in and an operator-allowed host',
|
||||
)
|
||||
}
|
||||
url.hostname = host
|
||||
url.pathname = url.pathname.replace(/\/+$/gu, '') || '/'
|
||||
return url.toString().replace(/\/$/u, '')
|
||||
}
|
||||
|
||||
export function assertResolvedAddressesAllowed(
|
||||
hostname: string,
|
||||
addresses: readonly ResolvedAddress[],
|
||||
policy: GiteaNetworkPolicy,
|
||||
): void {
|
||||
if (addresses.length === 0) {
|
||||
throw new NetworkPolicyError('Gitea hostname did not resolve')
|
||||
}
|
||||
const host = normalizedHostname(hostname)
|
||||
const explicitlyAllowed = (policy.allowedHosts ?? []).some(
|
||||
(allowed) => normalizedHostname(allowed) === host,
|
||||
)
|
||||
for (const candidate of addresses) {
|
||||
if (isIP(candidate.address) !== candidate.family) {
|
||||
throw new NetworkPolicyError('Resolver returned an invalid address')
|
||||
}
|
||||
const classification = classifyAddress(candidate.address)
|
||||
if (classification === 'public') continue
|
||||
if (
|
||||
classification === 'private' &&
|
||||
policy.privateNetworkPolicy === 'allow-explicit-hosts' &&
|
||||
explicitlyAllowed
|
||||
) {
|
||||
continue
|
||||
}
|
||||
throw new NetworkPolicyError(
|
||||
`Gitea hostname resolves to a blocked ${classification} address`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { createServer } from 'node:http'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
NodePinnedGetTransport,
|
||||
SafeHttpClient,
|
||||
SafeHttpError,
|
||||
type HostResolver,
|
||||
type PinnedGetTransport,
|
||||
type SafeHttpRequestRecord,
|
||||
type SafeHttpResponse,
|
||||
} from './safe-http-client'
|
||||
|
||||
class SequenceResolver implements HostResolver {
|
||||
calls: string[] = []
|
||||
constructor(
|
||||
private readonly answers: readonly (readonly {
|
||||
address: string
|
||||
family: 4 | 6
|
||||
}[])[],
|
||||
) {}
|
||||
async resolve(hostname: string) {
|
||||
this.calls.push(hostname)
|
||||
return this.answers[this.calls.length - 1] ?? this.answers.at(-1)!
|
||||
}
|
||||
}
|
||||
|
||||
class LedgerTransport implements PinnedGetTransport {
|
||||
calls: SafeHttpRequestRecord[] = []
|
||||
constructor(private readonly responses: readonly SafeHttpResponse[]) {}
|
||||
async get(request: SafeHttpRequestRecord) {
|
||||
this.calls.push(request)
|
||||
return this.responses[this.calls.length - 1]!
|
||||
}
|
||||
}
|
||||
|
||||
const response = (
|
||||
status: number,
|
||||
headers: Record<string, string> = {},
|
||||
): SafeHttpResponse => ({ status, headers, body: new Uint8Array() })
|
||||
|
||||
describe('SafeHttpClient', () => {
|
||||
it('uses the pinned address with the Node 24 all-address lookup contract', async () => {
|
||||
const server = createServer((_request, reply) => {
|
||||
reply.writeHead(200, { 'content-type': 'application/json' })
|
||||
reply.end('{"version":"fixture"}')
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
try {
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string')
|
||||
throw new Error('Fixture server did not expose a TCP port')
|
||||
const result = await new NodePinnedGetTransport().get(
|
||||
{
|
||||
method: 'GET',
|
||||
url: `http://unresolvable.invalid:${address.port}/api/v1/version`,
|
||||
address: { address: '127.0.0.1', family: 4 },
|
||||
headers: { accept: 'application/json' },
|
||||
},
|
||||
{ signal: AbortSignal.timeout(2_000), maximumBytes: 1_024 },
|
||||
)
|
||||
expect(result.status).toBe(200)
|
||||
expect(Buffer.from(result.body).toString('utf8')).toContain('fixture')
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolve())),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('pins a vetted address and exposes only GET to the transport ledger', async () => {
|
||||
const resolver = new SequenceResolver([[{ address: '8.8.8.8', family: 4 }]])
|
||||
const transport = new LedgerTransport([response(200)])
|
||||
const client = new SafeHttpClient({
|
||||
policy: { privateNetworkPolicy: 'deny' },
|
||||
resolver,
|
||||
transport,
|
||||
})
|
||||
await client.get(new URL('https://git.example/api/v1/version'))
|
||||
expect(transport.calls).toEqual([
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
address: { address: '8.8.8.8', family: 4 },
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('re-resolves every redirect and blocks DNS rebinding before the second request', async () => {
|
||||
const resolver = new SequenceResolver([
|
||||
[{ address: '8.8.8.8', family: 4 }],
|
||||
[{ address: '127.0.0.1', family: 4 }],
|
||||
])
|
||||
const transport = new LedgerTransport([
|
||||
response(302, { location: '/second' }),
|
||||
])
|
||||
const client = new SafeHttpClient({
|
||||
policy: { privateNetworkPolicy: 'deny' },
|
||||
resolver,
|
||||
transport,
|
||||
})
|
||||
await expect(
|
||||
client.get(new URL('https://git.example/first')),
|
||||
).rejects.toThrow('blocked loopback')
|
||||
expect(resolver.calls).toEqual(['git.example', 'git.example'])
|
||||
expect(transport.calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('strips authorization across origins and retains it on same-origin redirects', async () => {
|
||||
const resolver = new SequenceResolver(
|
||||
Array.from(
|
||||
{ length: 3 },
|
||||
() => [{ address: '8.8.8.8', family: 4 }] as const,
|
||||
),
|
||||
)
|
||||
const transport = new LedgerTransport([
|
||||
response(302, { location: '/same' }),
|
||||
response(302, { location: 'https://other.example/final' }),
|
||||
response(200),
|
||||
])
|
||||
const client = new SafeHttpClient({
|
||||
policy: { privateNetworkPolicy: 'deny' },
|
||||
resolver,
|
||||
transport,
|
||||
})
|
||||
await client.get(new URL('https://git.example/start'), {
|
||||
authorization: 'token never-log-me',
|
||||
})
|
||||
expect(transport.calls[1]!.headers.authorization).toBe('token never-log-me')
|
||||
expect(transport.calls[2]!.headers.authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it('blocks HTTPS downgrade and enforces the redirect limit', async () => {
|
||||
const resolver = new SequenceResolver([[{ address: '8.8.8.8', family: 4 }]])
|
||||
const downgrade = new LedgerTransport([
|
||||
response(302, { location: 'http://git.example/unsafe' }),
|
||||
])
|
||||
await expect(
|
||||
new SafeHttpClient({
|
||||
policy: { privateNetworkPolicy: 'deny' },
|
||||
resolver,
|
||||
transport: downgrade,
|
||||
}).get(new URL('https://git.example/start')),
|
||||
).rejects.toThrow('cannot downgrade')
|
||||
|
||||
const looping = new LedgerTransport([response(302, { location: '/again' })])
|
||||
await expect(
|
||||
new SafeHttpClient({
|
||||
policy: { privateNetworkPolicy: 'deny', maxRedirects: 0 },
|
||||
resolver,
|
||||
transport: looping,
|
||||
}).get(new URL('https://git.example/start')),
|
||||
).rejects.toThrow('redirect limit')
|
||||
})
|
||||
|
||||
it('maps timeout and response-size transport failures to safe errors', async () => {
|
||||
const resolver = new SequenceResolver([[{ address: '8.8.8.8', family: 4 }]])
|
||||
const timeoutTransport: PinnedGetTransport = {
|
||||
get: (_request, options) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
options.signal.addEventListener(
|
||||
'abort',
|
||||
() => reject(new Error('raw timeout detail')),
|
||||
{ once: true },
|
||||
)
|
||||
}),
|
||||
}
|
||||
await expect(
|
||||
new SafeHttpClient({
|
||||
policy: { privateNetworkPolicy: 'deny', requestTimeoutMs: 5 },
|
||||
resolver,
|
||||
transport: timeoutTransport,
|
||||
}).get(new URL('https://git.example/slow')),
|
||||
).rejects.toMatchObject({
|
||||
code: 'REMOTE_UNAVAILABLE',
|
||||
message: 'Gitea request timed out',
|
||||
})
|
||||
|
||||
const oversized: PinnedGetTransport = {
|
||||
get: async () => {
|
||||
throw new SafeHttpError('CONTENT_TOO_LARGE', 'bounded')
|
||||
},
|
||||
}
|
||||
await expect(
|
||||
new SafeHttpClient({
|
||||
policy: { privateNetworkPolicy: 'deny' },
|
||||
resolver,
|
||||
transport: oversized,
|
||||
}).get(new URL('https://git.example/large')),
|
||||
).rejects.toMatchObject({ code: 'CONTENT_TOO_LARGE' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,256 @@
|
||||
import { lookup } from 'node:dns/promises'
|
||||
import http from 'node:http'
|
||||
import https from 'node:https'
|
||||
|
||||
import {
|
||||
assertResolvedAddressesAllowed,
|
||||
type GiteaNetworkPolicy,
|
||||
NetworkPolicyError,
|
||||
type ResolvedAddress,
|
||||
} from './network-policy'
|
||||
|
||||
export interface SafeHttpResponse {
|
||||
readonly status: number
|
||||
readonly headers: Readonly<Record<string, string>>
|
||||
readonly body: Uint8Array
|
||||
}
|
||||
|
||||
export interface SafeHttpRequestRecord {
|
||||
readonly method: 'GET'
|
||||
readonly url: string
|
||||
readonly address: ResolvedAddress
|
||||
readonly headers: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export interface HostResolver {
|
||||
resolve(hostname: string): Promise<readonly ResolvedAddress[]>
|
||||
}
|
||||
|
||||
export interface PinnedGetTransport {
|
||||
get(
|
||||
request: SafeHttpRequestRecord,
|
||||
options: {
|
||||
readonly signal: AbortSignal
|
||||
readonly maximumBytes: number
|
||||
},
|
||||
): Promise<SafeHttpResponse>
|
||||
}
|
||||
|
||||
export class SafeHttpError extends Error {
|
||||
constructor(
|
||||
readonly code:
|
||||
| 'CONTENT_TOO_LARGE'
|
||||
| 'REMOTE_UNAVAILABLE'
|
||||
| 'TLS_ERROR'
|
||||
| 'RESPONSE_INVALID',
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'SafeHttpError'
|
||||
}
|
||||
}
|
||||
|
||||
export class NodeHostResolver implements HostResolver {
|
||||
async resolve(hostname: string): Promise<readonly ResolvedAddress[]> {
|
||||
const results = await lookup(hostname, { all: true, verbatim: true })
|
||||
return results.map(({ address, family }) => ({
|
||||
address,
|
||||
family: family as 4 | 6,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedHeaders(
|
||||
source: http.IncomingHttpHeaders,
|
||||
): Readonly<Record<string, string>> {
|
||||
const result: Record<string, string> = {}
|
||||
for (const [name, value] of Object.entries(source)) {
|
||||
if (value !== undefined)
|
||||
result[name.toLowerCase()] = Array.isArray(value)
|
||||
? value.join(', ')
|
||||
: value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export class NodePinnedGetTransport implements PinnedGetTransport {
|
||||
get(
|
||||
request: SafeHttpRequestRecord,
|
||||
options: { readonly signal: AbortSignal; readonly maximumBytes: number },
|
||||
): Promise<SafeHttpResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(request.url)
|
||||
const requester = url.protocol === 'https:' ? https.request : http.request
|
||||
const outgoing = requester(
|
||||
url,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: request.headers,
|
||||
signal: options.signal,
|
||||
servername: url.hostname,
|
||||
lookup: (_hostname, lookupOptions, callback) => {
|
||||
if (typeof lookupOptions === 'object' && lookupOptions.all) {
|
||||
const allAddresses = callback as unknown as (
|
||||
error: Error | null,
|
||||
addresses: Array<{ address: string; family: 4 | 6 }>,
|
||||
) => void
|
||||
allAddresses(null, [request.address])
|
||||
return
|
||||
}
|
||||
const oneAddress = callback as unknown as (
|
||||
error: Error | null,
|
||||
address: string,
|
||||
family: 4 | 6,
|
||||
) => void
|
||||
oneAddress(null, request.address.address, request.address.family)
|
||||
},
|
||||
},
|
||||
(incoming) => {
|
||||
const chunks: Buffer[] = []
|
||||
let total = 0
|
||||
incoming.on('data', (chunk: Buffer) => {
|
||||
total += chunk.byteLength
|
||||
if (total > options.maximumBytes) {
|
||||
incoming.destroy(
|
||||
new SafeHttpError(
|
||||
'CONTENT_TOO_LARGE',
|
||||
'Gitea response exceeded the configured byte limit',
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
chunks.push(chunk)
|
||||
})
|
||||
incoming.once('error', reject)
|
||||
incoming.on('end', () => {
|
||||
resolve({
|
||||
status: incoming.statusCode ?? 0,
|
||||
headers: normalizedHeaders(incoming.headers),
|
||||
body: Buffer.concat(chunks),
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
outgoing.once('error', (error) => {
|
||||
if (error instanceof SafeHttpError) return reject(error)
|
||||
const errorCode = (error as { readonly code?: unknown }).code
|
||||
const code = typeof errorCode === 'string' ? errorCode : ''
|
||||
reject(
|
||||
new SafeHttpError(
|
||||
code.startsWith('ERR_TLS') || code.includes('CERT')
|
||||
? 'TLS_ERROR'
|
||||
: 'REMOTE_UNAVAILABLE',
|
||||
'Gitea request failed',
|
||||
),
|
||||
)
|
||||
})
|
||||
outgoing.end()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function safeTarget(input: URL): void {
|
||||
if (
|
||||
(input.protocol !== 'https:' && input.protocol !== 'http:') ||
|
||||
input.username ||
|
||||
input.password ||
|
||||
input.hash
|
||||
) {
|
||||
throw new NetworkPolicyError('Gitea request target is not permitted')
|
||||
}
|
||||
}
|
||||
|
||||
function addressSort(left: ResolvedAddress, right: ResolvedAddress): number {
|
||||
return left.family - right.family || left.address.localeCompare(right.address)
|
||||
}
|
||||
|
||||
export class SafeHttpClient {
|
||||
readonly #resolver: HostResolver
|
||||
readonly #transport: PinnedGetTransport
|
||||
readonly #policy: GiteaNetworkPolicy
|
||||
|
||||
constructor(options: {
|
||||
readonly policy: GiteaNetworkPolicy
|
||||
readonly resolver?: HostResolver
|
||||
readonly transport?: PinnedGetTransport
|
||||
}) {
|
||||
this.#policy = options.policy
|
||||
this.#resolver = options.resolver ?? new NodeHostResolver()
|
||||
this.#transport = options.transport ?? new NodePinnedGetTransport()
|
||||
}
|
||||
|
||||
async get(
|
||||
target: URL,
|
||||
headers: Readonly<Record<string, string>> = {},
|
||||
): Promise<SafeHttpResponse> {
|
||||
const maximumRedirects = this.#policy.maxRedirects ?? 3
|
||||
const maximumBytes = this.#policy.maxResponseBytes ?? 1_048_576
|
||||
const timeoutMs = this.#policy.requestTimeoutMs ?? 15_000
|
||||
if (maximumRedirects < 0 || maximumRedirects > 10) {
|
||||
throw new RangeError('Maximum redirects must be between zero and ten')
|
||||
}
|
||||
if (maximumBytes < 1 || timeoutMs < 1) {
|
||||
throw new RangeError('HTTP limits must be positive')
|
||||
}
|
||||
let current = new URL(target)
|
||||
let activeHeaders = { ...headers }
|
||||
for (let redirect = 0; ; redirect += 1) {
|
||||
safeTarget(current)
|
||||
const addresses = [
|
||||
...(await this.#resolver.resolve(current.hostname)),
|
||||
].sort(addressSort)
|
||||
assertResolvedAddressesAllowed(current.hostname, addresses, this.#policy)
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
timer.unref()
|
||||
let response: SafeHttpResponse
|
||||
try {
|
||||
response = await this.#transport.get(
|
||||
{
|
||||
method: 'GET',
|
||||
url: current.toString(),
|
||||
address: addresses[0]!,
|
||||
headers: activeHeaders,
|
||||
},
|
||||
{ signal: controller.signal, maximumBytes },
|
||||
)
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
throw new SafeHttpError(
|
||||
'REMOTE_UNAVAILABLE',
|
||||
'Gitea request timed out',
|
||||
)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
if (![301, 302, 303, 307, 308].includes(response.status)) return response
|
||||
if (redirect >= maximumRedirects) {
|
||||
throw new SafeHttpError(
|
||||
'REMOTE_UNAVAILABLE',
|
||||
'Gitea redirect limit exceeded',
|
||||
)
|
||||
}
|
||||
const location = response.headers.location
|
||||
if (!location)
|
||||
throw new SafeHttpError(
|
||||
'RESPONSE_INVALID',
|
||||
'Gitea redirect omitted Location',
|
||||
)
|
||||
const next = new URL(location, current)
|
||||
safeTarget(next)
|
||||
if (current.protocol === 'https:' && next.protocol !== 'https:') {
|
||||
throw new NetworkPolicyError(
|
||||
'Gitea HTTPS redirects cannot downgrade transport security',
|
||||
)
|
||||
}
|
||||
if (next.origin !== current.origin) {
|
||||
const withoutAuthorization = { ...activeHeaders }
|
||||
delete withoutAuthorization.authorization
|
||||
activeHeaders = withoutAuthorization
|
||||
}
|
||||
current = next
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
decryptIntegrationSecret,
|
||||
encryptIntegrationSecret,
|
||||
type SecretBinding,
|
||||
} from './secret-envelope'
|
||||
|
||||
const binding: SecretBinding = {
|
||||
workspaceId: 'workspace-a',
|
||||
integrationId: 'integration-a',
|
||||
secretKind: 'gitea-token',
|
||||
}
|
||||
const oldKey = Buffer.alloc(32, 7)
|
||||
const activeKey = Buffer.alloc(32, 9)
|
||||
const ring = { activeVersion: 'v2', keys: { v1: oldKey, v2: activeKey } }
|
||||
|
||||
describe('integration secret envelope', () => {
|
||||
it('round-trips without exposing plaintext in the envelope', () => {
|
||||
const plaintext = 'gitea-secret-token-value'
|
||||
const envelope = encryptIntegrationSecret(plaintext, binding, ring)
|
||||
expect(JSON.stringify(envelope)).not.toContain(plaintext)
|
||||
expect(envelope).toMatchObject({
|
||||
algorithm: 'AES-256-GCM',
|
||||
envelopeVersion: 1,
|
||||
keyVersion: 'v2',
|
||||
})
|
||||
expect(decryptIntegrationSecret(envelope, binding, ring)).toBe(plaintext)
|
||||
})
|
||||
|
||||
it('decrypts an old key version through the key ring', () => {
|
||||
const envelope = encryptIntegrationSecret('old-secret', binding, {
|
||||
activeVersion: 'v1',
|
||||
keys: { v1: oldKey },
|
||||
})
|
||||
expect(decryptIntegrationSecret(envelope, binding, ring)).toBe('old-secret')
|
||||
})
|
||||
|
||||
it('rejects tampering, wrong AAD and unavailable key versions', () => {
|
||||
const envelope = encryptIntegrationSecret('bound-secret', binding, ring)
|
||||
expect(() =>
|
||||
decryptIntegrationSecret(
|
||||
{ ...envelope, ciphertext: Buffer.from('tampered').toString('base64') },
|
||||
binding,
|
||||
ring,
|
||||
),
|
||||
).toThrow('authentication failed')
|
||||
expect(() =>
|
||||
decryptIntegrationSecret(
|
||||
envelope,
|
||||
{ ...binding, workspaceId: 'workspace-b' },
|
||||
ring,
|
||||
),
|
||||
).toThrow('authentication failed')
|
||||
expect(() =>
|
||||
decryptIntegrationSecret(envelope, binding, {
|
||||
activeVersion: 'v3',
|
||||
keys: { v3: Buffer.alloc(32, 1) },
|
||||
}),
|
||||
).toThrow('key version is unavailable')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
|
||||
|
||||
export interface SecretBinding {
|
||||
readonly workspaceId: string
|
||||
readonly integrationId: string
|
||||
readonly secretKind: string
|
||||
}
|
||||
|
||||
export interface SecretEnvelopeV1 {
|
||||
readonly algorithm: 'AES-256-GCM'
|
||||
readonly envelopeVersion: 1
|
||||
readonly keyVersion: string
|
||||
readonly nonce: string
|
||||
readonly ciphertext: string
|
||||
readonly authenticationTag: string
|
||||
}
|
||||
|
||||
export interface IntegrationKeyRing {
|
||||
readonly activeVersion: string
|
||||
readonly keys: Readonly<Record<string, Uint8Array | string>>
|
||||
}
|
||||
|
||||
function keyBytes(key: Uint8Array | string): Buffer {
|
||||
const bytes =
|
||||
typeof key === 'string' ? Buffer.from(key, 'base64') : Buffer.from(key)
|
||||
if (bytes.byteLength !== 32)
|
||||
throw new TypeError('Integration encryption keys must contain 32 bytes')
|
||||
return bytes
|
||||
}
|
||||
|
||||
function assertBinding(binding: SecretBinding): void {
|
||||
for (const value of [
|
||||
binding.workspaceId,
|
||||
binding.integrationId,
|
||||
binding.secretKind,
|
||||
]) {
|
||||
if (value.length === 0 || value.length > 255)
|
||||
throw new TypeError(
|
||||
'Secret binding values must contain 1 to 255 characters',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function additionalData(
|
||||
binding: SecretBinding,
|
||||
envelopeVersion: number,
|
||||
keyVersion: string,
|
||||
): Buffer {
|
||||
assertBinding(binding)
|
||||
const fields = [
|
||||
binding.workspaceId,
|
||||
binding.integrationId,
|
||||
binding.secretKind,
|
||||
String(envelopeVersion),
|
||||
keyVersion,
|
||||
]
|
||||
return Buffer.from(
|
||||
fields
|
||||
.map((field) => `${Buffer.byteLength(field, 'utf8')}:${field}`)
|
||||
.join('|'),
|
||||
'utf8',
|
||||
)
|
||||
}
|
||||
|
||||
export function encryptIntegrationSecret(
|
||||
plaintext: string,
|
||||
binding: SecretBinding,
|
||||
keyRing: IntegrationKeyRing,
|
||||
): SecretEnvelopeV1 {
|
||||
if (plaintext.length === 0)
|
||||
throw new TypeError('Integration secret cannot be empty')
|
||||
const key = keyRing.keys[keyRing.activeVersion]
|
||||
if (!key)
|
||||
throw new TypeError('Active integration encryption key is unavailable')
|
||||
const nonce = randomBytes(12)
|
||||
const cipher = createCipheriv('aes-256-gcm', keyBytes(key), nonce)
|
||||
cipher.setAAD(additionalData(binding, 1, keyRing.activeVersion))
|
||||
const ciphertext = Buffer.concat([
|
||||
cipher.update(plaintext, 'utf8'),
|
||||
cipher.final(),
|
||||
])
|
||||
return Object.freeze({
|
||||
algorithm: 'AES-256-GCM',
|
||||
envelopeVersion: 1,
|
||||
keyVersion: keyRing.activeVersion,
|
||||
nonce: nonce.toString('base64'),
|
||||
ciphertext: ciphertext.toString('base64'),
|
||||
authenticationTag: cipher.getAuthTag().toString('base64'),
|
||||
})
|
||||
}
|
||||
|
||||
export function decryptIntegrationSecret(
|
||||
envelope: SecretEnvelopeV1,
|
||||
binding: SecretBinding,
|
||||
keyRing: IntegrationKeyRing,
|
||||
): string {
|
||||
if (envelope.algorithm !== 'AES-256-GCM' || envelope.envelopeVersion !== 1) {
|
||||
throw new TypeError('Integration secret envelope is unsupported')
|
||||
}
|
||||
const key = keyRing.keys[envelope.keyVersion]
|
||||
if (!key)
|
||||
throw new TypeError('Integration encryption key version is unavailable')
|
||||
try {
|
||||
const decipher = createDecipheriv(
|
||||
'aes-256-gcm',
|
||||
keyBytes(key),
|
||||
Buffer.from(envelope.nonce, 'base64'),
|
||||
)
|
||||
decipher.setAAD(
|
||||
additionalData(binding, envelope.envelopeVersion, envelope.keyVersion),
|
||||
)
|
||||
decipher.setAuthTag(Buffer.from(envelope.authenticationTag, 'base64'))
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(envelope.ciphertext, 'base64')),
|
||||
decipher.final(),
|
||||
]).toString('utf8')
|
||||
} catch {
|
||||
throw new TypeError('Integration secret envelope authentication failed')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user