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", () => {
|
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 = () => {};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
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";
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user