harden(authority): add pre-scale diagnostics and request safety

This commit is contained in:
Youzini-afk
2026-04-28 22:28:21 +08:00
parent aa62efe5b9
commit 2dee3cd8ff
9 changed files with 644 additions and 49 deletions

View File

@@ -61,6 +61,31 @@ export function normalizeAuthorityBlobPath(path = "") {
return normalized.replace(/\/+$/g, "");
}
function decodePathForValidation(path = "") {
try {
return decodeURIComponent(String(path || ""));
} catch {
return String(path || "");
}
}
function assertSafeAuthorityBlobPath(path = "", options = {}) {
const normalized = normalizeAuthorityBlobPath(path);
if (!normalized) {
if (options.allowEmpty) return "";
throw new Error("Authority Blob path is required");
}
const decoded = decodePathForValidation(normalized).replace(/\\/g, "/");
if (/^[A-Za-z]:(?:\/|$)/.test(decoded) || decoded.includes(":/")) {
throw new Error(`Unsafe Authority Blob path: ${normalized}`);
}
const segments = decoded.split("/").filter(Boolean);
if (segments.some((segment) => segment === "." || segment === "..")) {
throw new Error(`Unsafe Authority Blob path: ${normalized}`);
}
return normalized;
}
function normalizeBlobPayload(result = null) {
if (!result || typeof result !== "object" || Array.isArray(result)) return result;
const source = result.file || result.blob || result.result || result;
@@ -197,8 +222,9 @@ export class AuthorityBlobHttpClient {
}
async writeText(payload = {}) {
const path = assertSafeAuthorityBlobPath(payload.path || payload.name);
return await this.request(`${AUTHORITY_BLOB_ENDPOINT}/write-file`, {
path: normalizeAuthorityBlobPath(payload.path || payload.name),
path,
content: String(payload.text ?? payload.data ?? payload.content ?? ""),
encoding: "utf8",
createParents: true,
@@ -206,22 +232,25 @@ export class AuthorityBlobHttpClient {
}
async readJson(payload = {}) {
const path = assertSafeAuthorityBlobPath(payload.path || payload.name);
return await this.request(`${AUTHORITY_BLOB_ENDPOINT}/read-file`, {
path: normalizeAuthorityBlobPath(payload.path || payload.name),
path,
encoding: "utf8",
}, { signal: payload.signal });
}
async delete(payload = {}) {
const path = assertSafeAuthorityBlobPath(payload.path || payload.name);
return await this.request(`${AUTHORITY_BLOB_ENDPOINT}/delete`, {
path: normalizeAuthorityBlobPath(payload.path || payload.name),
path,
recursive: false,
}, { signal: payload.signal });
}
async stat(payload = {}) {
const path = assertSafeAuthorityBlobPath(payload.path || payload.name);
return await this.request(`${AUTHORITY_BLOB_ENDPOINT}/stat`, {
path: normalizeAuthorityBlobPath(payload.path || payload.name),
path,
}, { signal: payload.signal });
}
}
@@ -271,8 +300,7 @@ export class AuthorityBlobAdapter {
async writeJson(path, payload = null, options = {}) {
throwIfAborted(options.signal);
const normalizedPath = normalizeAuthorityBlobPath(path);
if (!normalizedPath) throw new Error("Authority Blob path is required");
const normalizedPath = assertSafeAuthorityBlobPath(path);
const result = await callClient(this.client, ["writeJson", "putJson", "writeFile", "put"], "writeJson", {
namespace: options.namespace || this.config.namespace,
path: normalizedPath,
@@ -287,8 +315,7 @@ export class AuthorityBlobAdapter {
async writeText(path, text = "", options = {}) {
throwIfAborted(options.signal);
const normalizedPath = normalizeAuthorityBlobPath(path);
if (!normalizedPath) throw new Error("Authority Blob path is required");
const normalizedPath = assertSafeAuthorityBlobPath(path);
const result = await callClient(this.client, ["writeText", "writeFile", "putText", "put"], "writeText", {
namespace: options.namespace || this.config.namespace,
path: normalizedPath,
@@ -303,7 +330,7 @@ export class AuthorityBlobAdapter {
async readJson(path, options = {}) {
throwIfAborted(options.signal);
const normalizedPath = normalizeAuthorityBlobPath(path);
const normalizedPath = assertSafeAuthorityBlobPath(path, { allowEmpty: true });
if (!normalizedPath) return normalizeAuthorityBlobReadResult({ exists: false }, "");
try {
const result = await callClient(this.client, ["readJson", "getJson", "readFile", "get"], "readJson", {
@@ -322,7 +349,7 @@ export class AuthorityBlobAdapter {
async delete(path, options = {}) {
throwIfAborted(options.signal);
const normalizedPath = normalizeAuthorityBlobPath(path);
const normalizedPath = assertSafeAuthorityBlobPath(path, { allowEmpty: true });
if (!normalizedPath) return normalizeAuthorityBlobDeleteResult({ exists: false }, "");
try {
const result = await callClient(this.client, ["delete", "deleteFile", "remove", "unlink"], "delete", {
@@ -341,7 +368,7 @@ export class AuthorityBlobAdapter {
async stat(path, options = {}) {
throwIfAborted(options.signal);
const normalizedPath = normalizeAuthorityBlobPath(path);
const normalizedPath = assertSafeAuthorityBlobPath(path, { allowEmpty: true });
if (!normalizedPath) return normalizeAuthorityBlobReadResult({ exists: false }, "");
try {
const result = await callClient(this.client, ["stat", "head", "metadata"], "stat", {

View File

@@ -245,6 +245,8 @@ export function normalizeAuthorityJobConfig(settings = {}, overrides = {}) {
failOpen: source.authorityFailOpen !== false && source.failOpen !== false,
preferStream: source.authorityJobPreferStream !== false && source.jobStreamPreferred !== false,
pollIntervalMs: normalizeInteger(source.authorityJobPollIntervalMs ?? source.pollIntervalMs, 1200, 250, 30000),
pollMaxIntervalMs: normalizeInteger(source.authorityJobPollMaxIntervalMs ?? source.pollMaxIntervalMs, 5000, 250, 60000),
pollBackoffFactor: Math.min(5, Math.max(1, Number(source.authorityJobPollBackoffFactor ?? source.pollBackoffFactor ?? 1.25) || 1.25)),
waitTimeoutMs: normalizeInteger(source.authorityJobWaitTimeoutMs ?? source.waitTimeoutMs, 0, 0, 3600000),
...overrides,
};
@@ -439,20 +441,75 @@ export class AuthorityJobAdapter {
id,
timeoutMs: normalizeInteger(options.timeoutMs, this.config.waitTimeoutMs, 0, 3600000),
});
return normalizeAuthorityJobRecord(result?.job || result?.result || result);
const normalized = normalizeAuthorityJobRecord(result?.job || result?.result || result);
return {
...normalized,
waitDiagnostics: {
mode: "client",
pollCount: 0,
elapsedMs: 0,
timeoutMs: normalizeInteger(options.timeoutMs, this.config.waitTimeoutMs, 0, 3600000),
terminal: normalized.terminal,
},
};
}
const startedAt = Date.now();
const timeoutMs = normalizeInteger(options.timeoutMs, this.config.waitTimeoutMs, 0, 3600000);
const pollIntervalMs = normalizeInteger(options.pollIntervalMs, this.config.pollIntervalMs, 250, 30000);
const initialPollIntervalMs = normalizeInteger(options.pollIntervalMs, this.config.pollIntervalMs, 250, 30000);
const maxPollIntervalMs = Math.max(
initialPollIntervalMs,
normalizeInteger(options.pollMaxIntervalMs, this.config.pollMaxIntervalMs, 250, 60000),
);
const backoffFactor = Math.min(5, Math.max(1, Number(options.pollBackoffFactor ?? this.config.pollBackoffFactor) || 1));
let pollIntervalMs = initialPollIntervalMs;
let pollCount = 0;
let lastJob = normalizeAuthorityJobRecord(null);
while (true) {
throwIfAborted(options.signal);
const job = await this.get(id, options);
if (job.terminal) return job;
if (timeoutMs > 0 && Date.now() - startedAt >= timeoutMs) {
return { ...job, status: "timeout", terminal: true, success: false, error: "wait timeout" };
pollCount += 1;
lastJob = job;
const elapsedMs = Date.now() - startedAt;
if (job.terminal) {
return {
...job,
waitDiagnostics: {
mode: "poll",
pollCount,
elapsedMs,
timeoutMs,
pollIntervalMs: initialPollIntervalMs,
maxPollIntervalMs,
backoffFactor,
terminal: true,
},
};
}
await sleep(pollIntervalMs, options.signal);
if (timeoutMs > 0 && elapsedMs >= timeoutMs) {
return {
...job,
status: "timeout",
terminal: true,
success: false,
error: "wait timeout",
waitDiagnostics: {
mode: "poll",
pollCount,
elapsedMs,
timeoutMs,
pollIntervalMs: initialPollIntervalMs,
maxPollIntervalMs,
backoffFactor,
terminal: false,
lastStatus: lastJob.status,
lastProgress: lastJob.progress,
},
};
}
const remainingMs = timeoutMs > 0 ? Math.max(0, timeoutMs - elapsedMs) : pollIntervalMs;
await sleep(timeoutMs > 0 ? Math.min(pollIntervalMs, remainingMs) : pollIntervalMs, options.signal);
pollIntervalMs = Math.min(maxPollIntervalMs, Math.max(initialPollIntervalMs, Math.ceil(pollIntervalMs * backoffFactor)));
}
}