mirror of
https://github.com/YuzuZensai/Cloudflare-DDNS-Updater.git
synced 2026-07-29 17:00:56 +00:00
🐛 fix: harden config validation against malformed entries
This commit is contained in:
@@ -61,13 +61,19 @@ describe("isZoneConfig", () => {
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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 = () => {};
|
||||
|
||||
@@ -13,12 +13,15 @@ export interface CloudflareConfig {
|
||||
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 == 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<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()}`);
|
||||
return Updater as any;
|
||||
return Updater as unknown as TestableUpdater;
|
||||
}
|
||||
|
||||
describe("Updater.init", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user