🐛 fix: harden config validation against malformed entries

This commit is contained in:
2026-07-01 13:22:55 +07:00
parent 27f9f20729
commit e5c9aca45d
8 changed files with 94 additions and 23 deletions
+20 -1
View File
@@ -61,13 +61,19 @@ describe("isZoneConfig", () => {
}); });
test("rejects a zone config missing a key", () => { test("rejects a zone config missing a key", () => {
const { proxied, ...rest } = validZone; const rest: Partial<typeof validZone> = { ...validZone };
delete rest.proxied;
expect(isZoneConfig(rest)).toBe(false); expect(isZoneConfig(rest)).toBe(false);
}); });
test("rejects a zone config with wrong types", () => { test("rejects a zone config with wrong types", () => {
expect(isZoneConfig({ ...validZone, ttl: "300" })).toBe(false); 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", () => { describe("isCloudflareConfig", () => {
@@ -94,6 +100,19 @@ describe("isCloudflareConfig", () => {
expect(isCloudflareConfig({ ...validConfig, zone: [{ id: "only-id" }] })).toBe(false); 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", () => { test("rejects an env token placeholder whose variable is unset", () => {
delete process.env.MISSING_TOKEN_VAR; delete process.env.MISSING_TOKEN_VAR;
const onMissing = () => {}; const onMissing = () => {};
+26 -15
View File
@@ -13,12 +13,15 @@ export interface CloudflareConfig {
zone: Array<ZoneConfig>; zone: Array<ZoneConfig>;
} }
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 === b) return true;
if (a == null || b == null) return false; if (a == null || b == null) return false;
if (a.length !== b.length) 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; if (a[i] !== b[i]) return false;
} }
return true; return true;
@@ -33,40 +36,48 @@ export function parseEnvironmentTokenPlaceholderName(token: string): string | nu
return token.split("{ENV_TOKEN:")[1].slice(0, -1); 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<string, unknown>;
if (!arraysEqual(Object.keys(object), ["id", "type", "name", "content", "ttl", "proxied"])) if (!arraysEqual(Object.keys(object), ["id", "type", "name", "content", "ttl", "proxied"]))
return false; return false;
return ( return (
object && !!object.id &&
object.id &&
typeof object.id == "string" && typeof object.id == "string" &&
object.type && !!object.type &&
typeof object.type == "string" && typeof object.type == "string" &&
object.name && !!object.name &&
typeof object.name == "string" && typeof object.name == "string" &&
object.content && !!object.content &&
typeof object.content == "string" && typeof object.content == "string" &&
object.ttl && !!object.ttl &&
typeof object.ttl == "number" && typeof object.ttl == "number" &&
typeof object.proxied == "boolean" typeof object.proxied == "boolean"
); );
} }
export function isCloudflareConfig( export function isCloudflareConfig(
object: any, input: unknown,
onMissingEnvToken?: (envTokenName: string) => void, onMissingEnvToken?: (envTokenName: string) => void,
): object is CloudflareConfig { ): input is CloudflareConfig {
if (typeof input !== "object" || input === null) return false;
const object = input as Record<string, unknown>;
if (!arraysEqual(Object.keys(object), ["token", "updateInterval", "zone"])) return false; if (!arraysEqual(Object.keys(object), ["token", "updateInterval", "zone"])) return false;
const res = const res =
object && !!object.token &&
object.token &&
typeof object.token == "string" && typeof object.token == "string" &&
object.updateInterval && !!object.updateInterval &&
typeof object.updateInterval == "number"; 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; if (!isZoneConfig(zone)) return false;
} }
+25
View File
@@ -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");
});
});
+1 -1
View File
@@ -10,7 +10,7 @@ const levels = {
debug: 6, debug: 6,
}; };
const level = () => { export const level = () => {
const env = process.env.NODE_ENV || "development"; const env = process.env.NODE_ENV || "development";
const isDevelopment = env === "development"; const isDevelopment = env === "development";
return isDevelopment ? "debug" : "info"; return isDevelopment ? "debug" : "info";
+2 -2
View File
@@ -6,12 +6,12 @@ import Updater from "./updater";
class App { class App {
public loadConfig(): void { public loadConfig(): void {
Logger.log("info", "Loading configuration"); Logger.info("Loading configuration");
Configuration.init(); Configuration.init();
} }
public loadENV(): void { public loadENV(): void {
Logger.log("info", "Loading environment"); Logger.info("Loading environment");
Environment.init(); Environment.init();
} }
public loadUpdater(): void { public loadUpdater(): void {
+9
View File
@@ -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 () => { test("getConfig throws for an unknown key", async () => {
const { default: Configuration } = await import(`./configuration?t=${Date.now()}-3`); const { default: Configuration } = await import(`./configuration?t=${Date.now()}-3`);
Configuration.init(); Configuration.init();
+9 -2
View File
@@ -59,9 +59,16 @@ function makeCloudflareConfig(overrides: Partial<CloudflareConfig> = {}): Cloudf
}; };
} }
async function freshUpdater() { interface TestableUpdater {
config: CloudflareConfig[];
start: () => void;
init: () => void;
update: (config: CloudflareConfig) => Promise<void>;
}
async function freshUpdater(): Promise<TestableUpdater> {
const { default: Updater } = await import(`./updater?t=${Date.now()}-${Math.random()}`); const { default: Updater } = await import(`./updater?t=${Date.now()}-${Math.random()}`);
return Updater as any; return Updater as unknown as TestableUpdater;
} }
describe("Updater.init", () => { describe("Updater.init", () => {
+2 -2
View File
@@ -68,8 +68,8 @@ class Updater {
return; return;
} }
IPv4 && Logger.info(`Current IPv4 address: ${IPv4}`); if (IPv4) Logger.info(`Current IPv4 address: ${IPv4}`);
IPv6 && Logger.info(`Current IPv6 address: ${IPv6}`); if (IPv6) Logger.info(`Current IPv6 address: ${IPv6}`);
for (const zone of cloudflareConfig.zone) { for (const zone of cloudflareConfig.zone) {
const api = new CloudflareAPI(token, zone.id); const api = new CloudflareAPI(token, zone.id);