From e5c9aca45d86cc6f0731923ba8065402dbae6b88 Mon Sep 17 00:00:00 2001 From: Yuzu Date: Wed, 1 Jul 2026 13:22:47 +0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix:=20harden=20config=20validat?= =?UTF-8?q?ion=20against=20malformed=20entries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libs/config-validation.test.ts | 21 ++++++++++++++- src/libs/config-validation.ts | 41 ++++++++++++++++++----------- src/libs/logger.test.ts | 25 ++++++++++++++++++ src/libs/logger.ts | 2 +- src/providers/app.ts | 4 +-- src/providers/configuration.test.ts | 9 +++++++ src/providers/updater.test.ts | 11 ++++++-- src/providers/updater.ts | 4 +-- 8 files changed, 94 insertions(+), 23 deletions(-) create mode 100644 src/libs/logger.test.ts diff --git a/src/libs/config-validation.test.ts b/src/libs/config-validation.test.ts index a30c268..6df209f 100644 --- a/src/libs/config-validation.test.ts +++ b/src/libs/config-validation.test.ts @@ -61,13 +61,19 @@ describe("isZoneConfig", () => { }); test("rejects a zone config missing a key", () => { - const { proxied, ...rest } = validZone; + const rest: Partial = { ...validZone }; + delete rest.proxied; expect(isZoneConfig(rest)).toBe(false); }); test("rejects a zone config with wrong types", () => { expect(isZoneConfig({ ...validZone, ttl: "300" })).toBe(false); }); + + test("rejects null and non-object input instead of throwing", () => { + expect(isZoneConfig(null)).toBe(false); + expect(isZoneConfig("not-an-object")).toBe(false); + }); }); describe("isCloudflareConfig", () => { @@ -94,6 +100,19 @@ describe("isCloudflareConfig", () => { expect(isCloudflareConfig({ ...validConfig, zone: [{ id: "only-id" }] })).toBe(false); }); + test("rejects a config with a null zone entry instead of throwing", () => { + expect(isCloudflareConfig({ ...validConfig, zone: [null] })).toBe(false); + }); + + test("rejects a config whose zone field is not an array instead of throwing", () => { + expect(isCloudflareConfig({ ...validConfig, zone: "not-an-array" })).toBe(false); + }); + + test("rejects null and non-object input instead of throwing", () => { + expect(isCloudflareConfig(null)).toBe(false); + expect(isCloudflareConfig("not-an-object")).toBe(false); + }); + test("rejects an env token placeholder whose variable is unset", () => { delete process.env.MISSING_TOKEN_VAR; const onMissing = () => {}; diff --git a/src/libs/config-validation.ts b/src/libs/config-validation.ts index 65969b7..83232fa 100644 --- a/src/libs/config-validation.ts +++ b/src/libs/config-validation.ts @@ -13,12 +13,15 @@ export interface CloudflareConfig { zone: Array; } -export function arraysEqual(a: any, b: any): boolean { +export function arraysEqual( + a: unknown[] | null | undefined, + b: unknown[] | null | undefined, +): boolean { if (a === b) return true; if (a == null || b == null) return false; if (a.length !== b.length) return false; - for (var i = 0; i < a.length; ++i) { + for (let i = 0; i < a.length; ++i) { if (a[i] !== b[i]) return false; } return true; @@ -33,40 +36,48 @@ export function parseEnvironmentTokenPlaceholderName(token: string): string | nu return token.split("{ENV_TOKEN:")[1].slice(0, -1); } -export function isZoneConfig(object: any): object is ZoneConfig { +export function isZoneConfig(input: unknown): input is ZoneConfig { + if (typeof input !== "object" || input === null) return false; + + const object = input as Record; + if (!arraysEqual(Object.keys(object), ["id", "type", "name", "content", "ttl", "proxied"])) return false; return ( - object && - object.id && + !!object.id && typeof object.id == "string" && - object.type && + !!object.type && typeof object.type == "string" && - object.name && + !!object.name && typeof object.name == "string" && - object.content && + !!object.content && typeof object.content == "string" && - object.ttl && + !!object.ttl && typeof object.ttl == "number" && typeof object.proxied == "boolean" ); } export function isCloudflareConfig( - object: any, + input: unknown, onMissingEnvToken?: (envTokenName: string) => void, -): object is CloudflareConfig { +): input is CloudflareConfig { + if (typeof input !== "object" || input === null) return false; + + const object = input as Record; + if (!arraysEqual(Object.keys(object), ["token", "updateInterval", "zone"])) return false; const res = - object && - object.token && + !!object.token && typeof object.token == "string" && - object.updateInterval && + !!object.updateInterval && typeof object.updateInterval == "number"; - for (let zone of object.zone) { + if (!Array.isArray(object.zone)) return false; + + for (const zone of object.zone) { if (!isZoneConfig(zone)) return false; } diff --git a/src/libs/logger.test.ts b/src/libs/logger.test.ts new file mode 100644 index 0000000..0968240 --- /dev/null +++ b/src/libs/logger.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { level } from "./logger"; + +describe("level", () => { + const originalNodeEnv = process.env.NODE_ENV; + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + }); + + test("returns debug in development", () => { + process.env.NODE_ENV = "development"; + expect(level()).toBe("debug"); + }); + + test("returns info in production", () => { + process.env.NODE_ENV = "production"; + expect(level()).toBe("info"); + }); + + test("defaults to development (debug) when NODE_ENV is unset", () => { + delete process.env.NODE_ENV; + expect(level()).toBe("debug"); + }); +}); diff --git a/src/libs/logger.ts b/src/libs/logger.ts index f1fce91..3b23cdd 100644 --- a/src/libs/logger.ts +++ b/src/libs/logger.ts @@ -10,7 +10,7 @@ const levels = { debug: 6, }; -const level = () => { +export const level = () => { const env = process.env.NODE_ENV || "development"; const isDevelopment = env === "development"; return isDevelopment ? "debug" : "info"; diff --git a/src/providers/app.ts b/src/providers/app.ts index 65716e4..383cdd9 100644 --- a/src/providers/app.ts +++ b/src/providers/app.ts @@ -6,12 +6,12 @@ import Updater from "./updater"; class App { public loadConfig(): void { - Logger.log("info", "Loading configuration"); + Logger.info("Loading configuration"); Configuration.init(); } public loadENV(): void { - Logger.log("info", "Loading environment"); + Logger.info("Loading environment"); Environment.init(); } public loadUpdater(): void { diff --git a/src/providers/configuration.test.ts b/src/providers/configuration.test.ts index 328d8b8..9eec2b2 100644 --- a/src/providers/configuration.test.ts +++ b/src/providers/configuration.test.ts @@ -48,6 +48,15 @@ describe("Configuration", () => { ]); }); + test("getConfig with no key returns all loaded configuration data", async () => { + const { default: Configuration } = await import(`./configuration?t=${Date.now()}-5`); + Configuration.init(); + + expect(Configuration.getConfig()).toEqual({ + UpdaterConfig: [{ token: "example-token", updateInterval: 60, zone: [] }], + }); + }); + test("getConfig throws for an unknown key", async () => { const { default: Configuration } = await import(`./configuration?t=${Date.now()}-3`); Configuration.init(); diff --git a/src/providers/updater.test.ts b/src/providers/updater.test.ts index c853d37..5908f61 100644 --- a/src/providers/updater.test.ts +++ b/src/providers/updater.test.ts @@ -59,9 +59,16 @@ function makeCloudflareConfig(overrides: Partial = {}): Cloudf }; } -async function freshUpdater() { +interface TestableUpdater { + config: CloudflareConfig[]; + start: () => void; + init: () => void; + update: (config: CloudflareConfig) => Promise; +} + +async function freshUpdater(): Promise { const { default: Updater } = await import(`./updater?t=${Date.now()}-${Math.random()}`); - return Updater as any; + return Updater as unknown as TestableUpdater; } describe("Updater.init", () => { diff --git a/src/providers/updater.ts b/src/providers/updater.ts index 66d3240..9506a67 100644 --- a/src/providers/updater.ts +++ b/src/providers/updater.ts @@ -68,8 +68,8 @@ class Updater { return; } - IPv4 && Logger.info(`Current IPv4 address: ${IPv4}`); - IPv6 && Logger.info(`Current IPv6 address: ${IPv6}`); + if (IPv4) Logger.info(`Current IPv4 address: ${IPv4}`); + if (IPv6) Logger.info(`Current IPv6 address: ${IPv6}`); for (const zone of cloudflareConfig.zone) { const api = new CloudflareAPI(token, zone.id);