feat(rcon): allow administrator-defined endpoints
This commit is contained in:
@@ -22,7 +22,7 @@ const savedMessages: Record<string, string> = {
|
||||
};
|
||||
|
||||
const errorMessages: Record<string, string> = {
|
||||
"invalid-connection": "Enter a valid allowlisted hostname, port, name, and password.",
|
||||
"invalid-connection": "Enter a valid DNS hostname, port, name, and password.",
|
||||
"duplicate-name": "Connection names must be unique.",
|
||||
configuration: "RCON credential encryption is not configured.",
|
||||
"save-failed": "The RCON connection could not be saved.",
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeRconOutput, validateRconCommand, validateRconConnection } from "./rcon-validation";
|
||||
|
||||
const allowed = "season4.somc.svc.cluster.local:25575,creative.somc.svc.cluster.local:25576";
|
||||
|
||||
describe("RCON validation", () => {
|
||||
it("normalizes an allowlisted internal endpoint", () => {
|
||||
it("normalizes any valid DNS hostname and port without deployment configuration", () => {
|
||||
expect(validateRconConnection({
|
||||
name: " Season 4 ",
|
||||
host: "SEASON4.SOMC.SVC.CLUSTER.LOCAL",
|
||||
port: "25575",
|
||||
password: "correct horse battery staple",
|
||||
}, { allowedEndpoints: allowed, passwordRequired: true })).toEqual({
|
||||
}, { passwordRequired: true })).toEqual({
|
||||
name: "Season 4",
|
||||
host: "season4.somc.svc.cluster.local",
|
||||
port: 25575,
|
||||
password: "correct horse battery staple",
|
||||
});
|
||||
|
||||
expect(validateRconConnection({
|
||||
name: "Creative",
|
||||
host: "creative.example.net",
|
||||
port: "43210",
|
||||
password: "secret",
|
||||
}, { passwordRequired: true })).toEqual({
|
||||
name: "Creative",
|
||||
host: "creative.example.net",
|
||||
port: 43210,
|
||||
password: "secret",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unlisted hosts, ports, IP literals, and suffix confusion", () => {
|
||||
for (const [host, port] of [
|
||||
["postgres.somc.svc.cluster.local", "5432"],
|
||||
["season4.somc.svc.cluster.local", "5432"],
|
||||
["season4.somc.svc.cluster.local.attacker.example", "25575"],
|
||||
["10.0.0.1", "25575"],
|
||||
]) {
|
||||
expect(validateRconConnection({ name: "Server", host, port, password: "secret" }, {
|
||||
allowedEndpoints: allowed,
|
||||
it("rejects IP literals and malformed DNS hostnames", () => {
|
||||
for (const host of ["10.0.0.1", "2001:db8::1", "season4.", "-season4.example", "season4..example"]) {
|
||||
expect(validateRconConnection({ name: "Server", host, port: "25575", password: "secret" }, {
|
||||
passwordRequired: true,
|
||||
})).toBeNull();
|
||||
}
|
||||
@@ -34,11 +38,9 @@ describe("RCON validation", () => {
|
||||
|
||||
it("allows a blank replacement password only while editing", () => {
|
||||
expect(validateRconConnection({ name: "Server", host: "season4.somc.svc.cluster.local", port: "25575", password: "" }, {
|
||||
allowedEndpoints: allowed,
|
||||
passwordRequired: false,
|
||||
})?.password).toBeNull();
|
||||
expect(validateRconConnection({ name: "Server", host: "season4.somc.svc.cluster.local", port: "25575", password: "" }, {
|
||||
allowedEndpoints: allowed,
|
||||
passwordRequired: true,
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
@@ -12,25 +12,19 @@ export type ValidRconConnection = {
|
||||
password: string | null;
|
||||
};
|
||||
|
||||
function endpointSet(value: string) {
|
||||
return new Set(value.split(",").map((endpoint) => endpoint.trim().toLowerCase()).filter(Boolean));
|
||||
}
|
||||
|
||||
export function validateRconConnection(
|
||||
input: { name: unknown; host: unknown; port: unknown; password: unknown },
|
||||
options: { allowedEndpoints?: string; passwordRequired: boolean },
|
||||
options: { passwordRequired: boolean },
|
||||
): ValidRconConnection | null {
|
||||
const name = typeof input.name === "string" ? input.name.trim() : "";
|
||||
const host = typeof input.host === "string" ? input.host.trim().toLowerCase() : "";
|
||||
const portText = typeof input.port === "string" || typeof input.port === "number" ? String(input.port).trim() : "";
|
||||
const passwordText = typeof input.password === "string" ? input.password : "";
|
||||
const port = Number(portText);
|
||||
const allowed = endpointSet(options.allowedEndpoints ?? process.env.RCON_ALLOWED_ENDPOINTS ?? "");
|
||||
|
||||
if (!name || name.length > 100 || CONTROL_PATTERN.test(name)) return null;
|
||||
if (!host || host.endsWith(".") || isIP(host) !== 0 || !HOST_PATTERN.test(host)) return null;
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) return null;
|
||||
if (!allowed.has(`${host}:${port}`)) return null;
|
||||
if (passwordText.length > 512 || CONTROL_PATTERN.test(passwordText)) return null;
|
||||
if (options.passwordRequired && !passwordText) return null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user