Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
15 changes: 12 additions & 3 deletions src/lib/cookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
23 changes: 19 additions & 4 deletions src/lib/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
Expand Down
20 changes: 20 additions & 0 deletions tests/unit/bird-baseline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down