From c68f36f1edb0406837527262b21cd34f18f02c0f Mon Sep 17 00:00:00 2001 From: ncihxaonn <189932551+ncihxaonn@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:43:56 +1000 Subject: [PATCH] fix: harden upstream cookie and git metadata reads --- CHANGELOG.md | 3 +++ src/lib/cookies.ts | 15 ++++++++++++--- src/lib/version.ts | 23 +++++++++++++++++++---- tests/unit/bird-baseline.test.ts | 20 ++++++++++++++++++++ 4 files changed, 54 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 554f4c1..879421c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,9 @@ - JSON and release-scan reads use opened file handles with identity checks to prevent path-swap races. - raw X news responses are no longer written to a debug file. +- browser cookies are accepted only from exact X/Twitter domains or their + subdomains; lookalike suffixes are rejected. +- Git worktree metadata is read through a stable, non-symlink file descriptor. ## Upstream Bird history (retained for provenance) diff --git a/src/lib/cookies.ts b/src/lib/cookies.ts index b9c537e..f06ff59 100644 --- a/src/lib/cookies.ts +++ b/src/lib/cookies.ts @@ -81,17 +81,26 @@ function pickCookieValue( cookies: Array<{ name?: string; value?: string; domain?: string }>, name: (typeof TWITTER_COOKIE_NAMES)[number], ): string | null { - const matches = cookies.filter((c) => c?.name === name && typeof c.value === 'string'); + const matchesDomain = (domain: string | undefined, expected: string): boolean => { + const normalized = (domain ?? '').trim().toLowerCase().replace(/^\./, ''); + return normalized === expected || normalized.endsWith(`.${expected}`); + }; + const matches = cookies.filter( + (cookie) => + cookie?.name === name && + typeof cookie.value === 'string' && + (matchesDomain(cookie.domain, 'x.com') || matchesDomain(cookie.domain, 'twitter.com')), + ); if (matches.length === 0) { return null; } - const preferred = matches.find((c) => (c.domain ?? '').endsWith('x.com')); + const preferred = matches.find((cookie) => matchesDomain(cookie.domain, 'x.com')); if (preferred?.value) { return preferred.value; } - const twitter = matches.find((c) => (c.domain ?? '').endsWith('twitter.com')); + const twitter = matches.find((cookie) => matchesDomain(cookie.domain, 'twitter.com')); if (twitter?.value) { return twitter.value; } diff --git a/src/lib/version.ts b/src/lib/version.ts index 685fb46..06d36f1 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -149,15 +149,26 @@ export function resolveGitSha(importMetaUrl?: string): string | null { let dir = resolveStartDir(importMetaUrl); for (let i = 0; i < 10; i += 1) { const dotGit = path.join(dir, '.git'); + let descriptor: number | null = null; try { - const stat = fs.statSync(dotGit); - if (stat.isDirectory()) { + const noFollow = process.platform === 'win32' ? 0 : fs.constants.O_NOFOLLOW; + descriptor = fs.openSync(dotGit, fs.constants.O_RDONLY | noFollow); + const openedStats = fs.fstatSync(descriptor); + const pathStats = fs.lstatSync(dotGit); + if ( + pathStats.isSymbolicLink() || + openedStats.dev !== pathStats.dev || + openedStats.ino !== pathStats.ino + ) { + throw new Error('Unstable Git metadata path'); + } + if (openedStats.isDirectory()) { const sha = resolveGitShaFromGitDir(dotGit); if (sha) { return sha; } - } else if (stat.isFile()) { - const txt = fs.readFileSync(dotGit, 'utf8'); + } else if (openedStats.isFile()) { + const txt = fs.readFileSync(descriptor, 'utf8'); const match = GITDIR_REGEX.exec(txt); const gitDir = match?.[1]?.trim(); if (gitDir) { @@ -170,6 +181,10 @@ export function resolveGitSha(importMetaUrl?: string): string | null { } } catch { // ignore + } finally { + if (descriptor !== null) { + fs.closeSync(descriptor); + } } const parent = path.dirname(dir); diff --git a/tests/unit/bird-baseline.test.ts b/tests/unit/bird-baseline.test.ts index 5056e5c..d6d7a46 100644 --- a/tests/unit/bird-baseline.test.ts +++ b/tests/unit/bird-baseline.test.ts @@ -207,6 +207,26 @@ describe('Bird credential resolution baseline', () => { expect(getCookiesMock.mock.calls.map(([input]) => input.browsers)).toEqual([['safari'], ['chrome']]); }); + it('rejects lookalike cookie domains and accepts exact X domains', async () => { + getCookiesMock.mockResolvedValue({ + cookies: [ + { name: 'auth_token', value: 'lookalike-auth', domain: 'notx.com' }, + { name: 'ct0', value: 'lookalike-ct0', domain: 'eviltwitter.com' }, + { name: 'auth_token', value: 'browser-auth', domain: '.x.com' }, + { name: 'ct0', value: 'browser-ct0', domain: 'api.x.com' }, + ], + warnings: [], + }); + + const result = await resolveCredentials({ cookieSource: 'chrome' }); + + expect(result.cookies).toMatchObject({ + authToken: 'browser-auth', + ct0: 'browser-ct0', + source: 'Chrome default profile', + }); + }); + it('documents the upstream environment-before-browser priority', async () => { process.env.AUTH_TOKEN = 'env-auth'; process.env.CT0 = 'env-ct0';