pairing: enforce strict account-scoped state

This commit is contained in:
Gustavo Madeira Santana
2026-02-26 00:31:24 -05:00
parent d9b19e5970
commit 91a3f0a3fe
6 changed files with 152 additions and 19 deletions

View File

@@ -35,6 +35,7 @@ async function withTempStateDir<T>(fn: (stateDir: string) => Promise<T>) {
}
async function writeJsonFixture(filePath: string, value: unknown) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
@@ -42,6 +43,11 @@ function resolvePairingFilePath(stateDir: string, channel: string) {
return path.join(resolveOAuthDir(process.env, stateDir), `${channel}-pairing.json`);
}
function resolveAllowFromFilePath(stateDir: string, channel: string, accountId?: string) {
const suffix = accountId ? `-${accountId}` : "";
return path.join(resolveOAuthDir(process.env, stateDir), `${channel}${suffix}-allowFrom.json`);
}
async function writeAllowFromFixture(params: {
stateDir: string;
channel: string;
@@ -273,8 +279,68 @@ describe("pairing store", () => {
const scoped = readChannelAllowFromStoreSync("telegram", process.env, "yy");
const channelScoped = readChannelAllowFromStoreSync("telegram");
expect(scoped).toEqual(["1002", "1001", "1002"]);
expect(channelScoped).toEqual(["1001", "1001"]);
expect(scoped).toEqual(["1002", "1001"]);
expect(channelScoped).toEqual(["1001"]);
});
});
it("does not read legacy channel-scoped allowFrom for non-default account ids", async () => {
await withTempStateDir(async (stateDir) => {
await writeAllowFromFixture({
stateDir,
channel: "telegram",
allowFrom: ["1001", "*", "1002", "1001"],
});
await writeAllowFromFixture({
stateDir,
channel: "telegram",
accountId: "yy",
allowFrom: ["1003"],
});
const asyncScoped = await readChannelAllowFromStore("telegram", process.env, "yy");
const syncScoped = readChannelAllowFromStoreSync("telegram", process.env, "yy");
expect(asyncScoped).toEqual(["1003"]);
expect(syncScoped).toEqual(["1003"]);
});
});
it("does not fall back to legacy allowFrom when scoped file exists but is empty", async () => {
await withTempStateDir(async (stateDir) => {
await writeAllowFromFixture({
stateDir,
channel: "telegram",
allowFrom: ["1001"],
});
await writeAllowFromFixture({
stateDir,
channel: "telegram",
accountId: "yy",
allowFrom: [],
});
const asyncScoped = await readChannelAllowFromStore("telegram", process.env, "yy");
const syncScoped = readChannelAllowFromStoreSync("telegram", process.env, "yy");
expect(asyncScoped).toEqual([]);
expect(syncScoped).toEqual([]);
});
});
it("keeps async and sync reads aligned for malformed scoped allowFrom files", async () => {
await withTempStateDir(async (stateDir) => {
await writeAllowFromFixture({
stateDir,
channel: "telegram",
allowFrom: ["1001"],
});
const malformedScopedPath = resolveAllowFromFilePath(stateDir, "telegram", "yy");
await fs.mkdir(path.dirname(malformedScopedPath), { recursive: true });
await fs.writeFile(malformedScopedPath, "{ this is not json\n", "utf8");
const asyncScoped = await readChannelAllowFromStore("telegram", process.env, "yy");
const syncScoped = readChannelAllowFromStoreSync("telegram", process.env, "yy");
expect(asyncScoped).toEqual([]);
expect(syncScoped).toEqual([]);
});
});

View File

@@ -243,7 +243,9 @@ function normalizeAllowEntry(channel: PairingChannel, entry: string): string {
function normalizeAllowFromList(channel: PairingChannel, store: AllowFromStore): string[] {
const list = Array.isArray(store.allowFrom) ? store.allowFrom : [];
return list.map((v) => normalizeAllowEntry(channel, String(v))).filter(Boolean);
return dedupePreserveOrder(
list.map((v) => normalizeAllowEntry(channel, String(v))).filter(Boolean),
);
}
function normalizeAllowFromInput(channel: PairingChannel, entry: string | number): string {
@@ -268,20 +270,46 @@ async function readAllowFromStateForPath(
channel: PairingChannel,
filePath: string,
): Promise<string[]> {
const { value } = await readJsonFile<AllowFromStore>(filePath, {
return (await readAllowFromStateForPathWithExists(channel, filePath)).entries;
}
async function readAllowFromStateForPathWithExists(
channel: PairingChannel,
filePath: string,
): Promise<{ entries: string[]; exists: boolean }> {
const { value, exists } = await readJsonFile<AllowFromStore>(filePath, {
version: 1,
allowFrom: [],
});
return normalizeAllowFromList(channel, value);
const entries = normalizeAllowFromList(channel, value);
return { entries, exists };
}
function readAllowFromStateForPathSync(channel: PairingChannel, filePath: string): string[] {
return readAllowFromStateForPathSyncWithExists(channel, filePath).entries;
}
function readAllowFromStateForPathSyncWithExists(
channel: PairingChannel,
filePath: string,
): { entries: string[]; exists: boolean } {
let raw = "";
try {
raw = fs.readFileSync(filePath, "utf8");
} catch (err) {
const code = (err as { code?: string }).code;
if (code === "ENOENT") {
return { entries: [], exists: false };
}
return { entries: [], exists: false };
}
try {
const raw = fs.readFileSync(filePath, "utf8");
const parsed = JSON.parse(raw) as AllowFromStore;
return normalizeAllowFromList(channel, parsed);
const entries = normalizeAllowFromList(channel, parsed);
return { entries, exists: true };
} catch {
return [];
// Keep parity with async reads: malformed JSON still means the file exists.
return { entries: [], exists: true };
}
}
@@ -306,6 +334,24 @@ async function writeAllowFromState(filePath: string, allowFrom: string[]): Promi
} satisfies AllowFromStore);
}
async function readNonDefaultAccountAllowFrom(params: {
channel: PairingChannel;
env: NodeJS.ProcessEnv;
accountId: string;
}): Promise<string[]> {
const scopedPath = resolveAllowFromPath(params.channel, params.env, params.accountId);
return await readAllowFromStateForPath(params.channel, scopedPath);
}
function readNonDefaultAccountAllowFromSync(params: {
channel: PairingChannel;
env: NodeJS.ProcessEnv;
accountId: string;
}): string[] {
const scopedPath = resolveAllowFromPath(params.channel, params.env, params.accountId);
return readAllowFromStateForPathSync(params.channel, scopedPath);
}
async function updateAllowFromStoreEntry(params: {
channel: PairingChannel;
entry: string | number;
@@ -348,11 +394,15 @@ export async function readChannelAllowFromStore(
return await readAllowFromStateForPath(channel, filePath);
}
if (!shouldIncludeLegacyAllowFromEntries(normalizedAccountId)) {
return await readNonDefaultAccountAllowFrom({
channel,
env,
accountId: normalizedAccountId,
});
}
const scopedPath = resolveAllowFromPath(channel, env, accountId);
const scopedEntries = await readAllowFromStateForPath(channel, scopedPath);
if (!shouldIncludeLegacyAllowFromEntries(normalizedAccountId)) {
return scopedEntries;
}
// Backward compatibility: legacy channel-level allowFrom store was unscoped.
// Keep honoring it for default account to prevent re-pair prompts after upgrades.
const legacyPath = resolveAllowFromPath(channel, env);
@@ -371,11 +421,15 @@ export function readChannelAllowFromStoreSync(
return readAllowFromStateForPathSync(channel, filePath);
}
if (!shouldIncludeLegacyAllowFromEntries(normalizedAccountId)) {
return readNonDefaultAccountAllowFromSync({
channel,
env,
accountId: normalizedAccountId,
});
}
const scopedPath = resolveAllowFromPath(channel, env, accountId);
const scopedEntries = readAllowFromStateForPathSync(channel, scopedPath);
if (!shouldIncludeLegacyAllowFromEntries(normalizedAccountId)) {
return scopedEntries;
}
const legacyPath = resolveAllowFromPath(channel, env);
const legacyEntries = readAllowFromStateForPathSync(channel, legacyPath);
return dedupePreserveOrder([...scopedEntries, ...legacyEntries]);
@@ -515,11 +569,12 @@ export async function upsertChannelPairingRequest(params: {
nowMs,
);
reqs = prunedExpired;
const normalizedMatchingAccountId = normalizePairingAccountId(normalizedAccountId);
const existingIdx = reqs.findIndex((r) => {
if (r.id !== id) {
return false;
}
return requestMatchesAccountId(r, normalizePairingAccountId(normalizedAccountId));
return requestMatchesAccountId(r, normalizedMatchingAccountId);
});
const existingCodes = new Set(
reqs.map((req) =>