Archived
feat: add external path access allowlist
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
|
||||
import { machinesApi, piWebApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||
import { filesApi, machinesApi, piWebApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||
|
||||
const workspace: Workspace = {
|
||||
id: "w/1",
|
||||
@@ -84,6 +84,26 @@ describe("session API compatibility", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("machine-scoped file suggestion API", () => {
|
||||
it("uses the workspace-scoped route when the caller has enabled workspace-scoped suggestions", async () => {
|
||||
const fetchMock = stubJsonFetch([]);
|
||||
|
||||
await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a", workspaceScoped: true });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/files?q=README&scope=tracked");
|
||||
});
|
||||
|
||||
it("falls back to the legacy cwd route when workspace-scoped suggestions are not enabled", async () => {
|
||||
const fetchMock = stubJsonFetch([]);
|
||||
|
||||
await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a" });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/files?q=README&scope=tracked&cwd=%2Frepo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("machine-scoped terminal command-run API", () => {
|
||||
it("deletes workspaces through the selected machine scope", async () => {
|
||||
const fetchMock = stubJsonFetch(commandRun);
|
||||
|
||||
@@ -209,14 +209,21 @@ export interface FileSuggestionQueryOptions {
|
||||
mode?: "file" | "path" | undefined;
|
||||
scope?: "tracked" | "all" | undefined;
|
||||
machineId?: string | undefined;
|
||||
projectId?: string | undefined;
|
||||
workspaceId?: string | undefined;
|
||||
workspaceScoped?: boolean | undefined;
|
||||
}
|
||||
|
||||
export const filesApi = {
|
||||
files: (cwd: string, query: string, options: FileSuggestionQueryOptions = {}) => {
|
||||
const params = new URLSearchParams({ cwd, q: query });
|
||||
const params = new URLSearchParams({ q: query });
|
||||
if (options.kind !== undefined) params.set("kind", options.kind);
|
||||
if (options.mode !== undefined) params.set("mode", options.mode);
|
||||
if (options.scope !== undefined) params.set("scope", options.scope);
|
||||
if (options.workspaceScoped === true && options.projectId !== undefined && options.workspaceId !== undefined) {
|
||||
return request(`${machinePrefix(options.machineId)}/projects/${encodeURIComponent(options.projectId)}/workspaces/${encodeURIComponent(options.workspaceId)}/files?${params.toString()}`, arrayOf(parseFileSuggestion));
|
||||
}
|
||||
params.set("cwd", cwd);
|
||||
return request(`${machinePrefix(options.machineId)}/files?${params.toString()}`, arrayOf(parseFileSuggestion));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("federated route contract", () => {
|
||||
ignoreParseFailure(workspacesApi.deleteWorkspace("p 1", "w 1", machineId)),
|
||||
ignoreParseFailure(workspacesApi.workspaceTree("p 1", "w 1", "src", machineId)),
|
||||
ignoreParseFailure(workspacesApi.workspaceFile("p 1", "w 1", "README.md", machineId)),
|
||||
ignoreParseFailure(filesApi.files("/repo", "README", { kind: "tracked", mode: "file", machineId })),
|
||||
ignoreParseFailure(filesApi.files("/repo", "README", { kind: "tracked", mode: "file", projectId: "p 1", workspaceId: "w 1", machineId, workspaceScoped: true })),
|
||||
ignoreParseFailure(gitApi.gitStatus("p 1", "w 1", machineId)),
|
||||
ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)),
|
||||
ignoreParseFailure(sessionsApi.sessions("/repo", machineId)),
|
||||
|
||||
@@ -7,14 +7,14 @@ describe("API parsers", () => {
|
||||
expect(parsePiWebConfigResponse({
|
||||
path: "/tmp/config.json",
|
||||
exists: true,
|
||||
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } } },
|
||||
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true },
|
||||
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 },
|
||||
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] } },
|
||||
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
||||
})).toEqual({
|
||||
path: "/tmp/config.json",
|
||||
exists: true,
|
||||
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } } },
|
||||
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true },
|
||||
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 },
|
||||
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] } },
|
||||
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -445,6 +445,8 @@ function parsePiWebConfigValues(value: unknown): PiWebConfigValues {
|
||||
...optionalField("allowedHosts", optionalAllowedHosts(record["allowedHosts"])),
|
||||
...optionalField("shortcuts", optionalShortcuts(record["shortcuts"])),
|
||||
...optionalField("plugins", optionalPlugins(record["plugins"])),
|
||||
...optionalField("pathAccess", optionalPathAccess(record["pathAccess"])),
|
||||
...optionalField("maxUploadBytes", optionalNumber(record, "maxUploadBytes")),
|
||||
...optionalField("spawnSessions", optionalBoolean(record, "spawnSessions")),
|
||||
...optionalField("subsessions", optionalBoolean(record, "subsessions")),
|
||||
};
|
||||
@@ -453,10 +455,33 @@ function parsePiWebConfigValues(value: unknown): PiWebConfigValues {
|
||||
function optionalAllowedHosts(value: unknown): PiWebConfigValues["allowedHosts"] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === true) return true;
|
||||
if (Array.isArray(value) && value.every((item) => typeof item === "string")) return value;
|
||||
if (isStringArray(value)) return value;
|
||||
throw new Error("Invalid PI WEB allowedHosts field");
|
||||
}
|
||||
|
||||
function optionalPathAccess(value: unknown): PiWebConfigValues["pathAccess"] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!isRecord(value)) throw new Error("Invalid PI WEB pathAccess field");
|
||||
const allowedPaths = value["allowedPaths"];
|
||||
return {
|
||||
...optionalField("allowedPaths", optionalStringArray(allowedPaths, "pathAccess.allowedPaths")),
|
||||
};
|
||||
}
|
||||
|
||||
function optionalStringArray(value: unknown, field: string): string[] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (isNonEmptyStringArray(value)) return value;
|
||||
throw new Error(`Invalid PI WEB ${field} field`);
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
||||
}
|
||||
|
||||
function isNonEmptyStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === "string" && item !== "");
|
||||
}
|
||||
|
||||
function optionalShortcuts(value: unknown): PiWebShortcutConfig | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!isRecord(value) || Array.isArray(value)) throw new Error("Invalid PI WEB shortcuts field");
|
||||
|
||||
@@ -1004,6 +1004,14 @@ export class PiWebApp extends LitElement {
|
||||
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload);
|
||||
}
|
||||
|
||||
private supportsWorkspaceFileSuggestions(machineId = selectedMachineId(this.state)): boolean {
|
||||
if (machineId === "local") return true;
|
||||
// COMPAT-CAP workspace.fileSuggestions: remote machines without this
|
||||
// capability stay on the legacy cwd-based /files route.
|
||||
const runtime = this.state.machineRuntimes[machineId];
|
||||
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.workspaceFileSuggestions);
|
||||
}
|
||||
|
||||
private archivedDeleteUnavailableMessage(): string {
|
||||
const machineName = this.state.selectedMachine?.name ?? "this machine";
|
||||
return `Update and restart Pi-Web on ${machineName} to delete archived sessions.`;
|
||||
@@ -1738,7 +1746,7 @@ export class PiWebApp extends LitElement {
|
||||
<div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}</div>
|
||||
${state.selectedSession ? html`
|
||||
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isSendingPrompt=${state.sendingPrompts[state.selectedSession.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
|
||||
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery) => { this.sendPrompt(text, streamingBehavior, attachments, delivery); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
|
||||
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .projectId=${state.selectedWorkspace?.projectId} .workspaceId=${state.selectedWorkspace?.id} .workspaceScopedFileSuggestions=${this.supportsWorkspaceFileSuggestions()} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery) => { this.sendPrompt(text, streamingBehavior, attachments, delivery); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
|
||||
<status-bar .status=${state.status}></status-bar>
|
||||
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
|
||||
${state.modelDialog !== undefined ? html`<command-picker title=${state.modelDialog.title} .searchable=${true} .options=${state.modelDialog.options} .selectedValue=${state.modelDialog.selectedValue} .onPick=${(value: string) => { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}></command-picker>` : null}
|
||||
|
||||
@@ -33,6 +33,9 @@ export class PromptEditor extends LitElement {
|
||||
@property() sessionId?: string;
|
||||
@property() cwd?: string;
|
||||
@property() machineId = "local";
|
||||
@property() projectId?: string;
|
||||
@property() workspaceId?: string;
|
||||
@property({ type: Boolean }) workspaceScopedFileSuggestions = false;
|
||||
@property({ type: Boolean }) canSteer = false;
|
||||
@property({ type: Boolean }) isCompacting = false;
|
||||
@property({ type: Boolean }) canStop = false;
|
||||
@@ -293,7 +296,7 @@ export class PromptEditor extends LitElement {
|
||||
...(command.description === undefined ? {} : { description: command.description }),
|
||||
}));
|
||||
} else if (trigger.kind === "file" && this.cwd !== undefined && this.cwd !== "") {
|
||||
const files = await api.files(this.cwd, trigger.query, { scope: trigger.fileScope, machineId: this.machineId }).catch(emptyFileSuggestions);
|
||||
const files = await api.files(this.cwd, trigger.query, { scope: trigger.fileScope, machineId: this.machineId, projectId: this.projectId, workspaceId: this.workspaceId, workspaceScoped: this.workspaceScopedFileSuggestions }).catch(emptyFileSuggestions);
|
||||
if (version !== this.requestVersion) return;
|
||||
this.completions = files
|
||||
.slice(0, 12)
|
||||
|
||||
@@ -71,6 +71,14 @@ export class SettingsGeneralPanel extends LitElement {
|
||||
<small>Enter one host per line, or choose “Allow every host” to write <code>true</code>.</small>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span>External filesystem roots</span>
|
||||
</span>
|
||||
<textarea .value=${this.draft.allowedPathsText} rows="4" placeholder="~/SDKs /opt/reference" spellcheck="false" @input=${(event: Event) => { this.updateDraft({ allowedPathsText: textAreaValue(event) }); }}></textarea>
|
||||
<small>Global allowlist for absolute <code>@</code> completions and file explorer reads outside a workspace. Enter one absolute path, Windows absolute path, or <code>~</code>-prefixed path per line. Leave empty to deny external paths by default.</small>
|
||||
</label>
|
||||
|
||||
${this.renderEffectiveConfig()}
|
||||
|
||||
<footer class="form-actions">
|
||||
@@ -102,6 +110,7 @@ export class SettingsGeneralPanel extends LitElement {
|
||||
<div><dt>Host</dt><dd>${effective.host ?? html`<span class="muted">127.0.0.1 default</span>`}</dd></div>
|
||||
<div><dt>Port</dt><dd>${effective.port ?? html`<span class="muted">8504 default</span>`}</dd></div>
|
||||
<div><dt>Allowed hosts</dt><dd>${formatAllowedHosts(effective.allowedHosts)}</dd></div>
|
||||
<div><dt>External roots</dt><dd>${formatAllowedPaths(effective.pathAccess?.allowedPaths)}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
`;
|
||||
@@ -173,6 +182,11 @@ function formatAllowedHosts(value: PiWebConfigValues["allowedHosts"]): string |
|
||||
return html`<span class="muted">Unset</span>`;
|
||||
}
|
||||
|
||||
function formatAllowedPaths(value: string[] | undefined): string | TemplateResult {
|
||||
if (value === undefined || value.length === 0) return html`<span class="muted">External paths denied</span>`;
|
||||
return value.join(", ");
|
||||
}
|
||||
|
||||
function inputValue(event: Event): string {
|
||||
return event.target instanceof HTMLInputElement ? event.target.value : "";
|
||||
}
|
||||
|
||||
@@ -3,36 +3,61 @@ import { configFromDraft, draftFromConfig } from "./settingsConfigDraft";
|
||||
|
||||
describe("settings config drafts", () => {
|
||||
it("converts PI WEB config values to editable general settings drafts", () => {
|
||||
expect(draftFromConfig({ host: "0.0.0.0", port: 8504, allowedHosts: ["example.local", "192.168.1.20"] })).toEqual({
|
||||
expect(draftFromConfig({ host: "0.0.0.0", port: 8504, allowedHosts: ["example.local", "192.168.1.20"], pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } })).toEqual({
|
||||
host: "0.0.0.0",
|
||||
port: "8504",
|
||||
allowedHostsMode: "list",
|
||||
allowedHostsText: "example.local\n192.168.1.20",
|
||||
allowedPathsText: "/tmp\n~/SDKs",
|
||||
});
|
||||
expect(draftFromConfig({ allowedHosts: true }).allowedHostsMode).toBe("all");
|
||||
});
|
||||
|
||||
it("converts drafts back to config while preserving shortcut and plugin preferences", () => {
|
||||
it("converts drafts back to config while preserving non-general preferences", () => {
|
||||
expect(configFromDraft({
|
||||
host: " 127.0.0.1 ",
|
||||
port: "9000",
|
||||
allowedHostsMode: "list",
|
||||
allowedHostsText: "example.local, 192.168.1.20\n",
|
||||
}, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } } })).toEqual({
|
||||
allowedPathsText: "/tmp\n~/SDKs\n",
|
||||
}, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, maxUploadBytes: 1234 })).toEqual({
|
||||
host: "127.0.0.1",
|
||||
port: 9000,
|
||||
allowedHosts: ["example.local", "192.168.1.20"],
|
||||
shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null },
|
||||
plugins: { info: { enabled: false } },
|
||||
pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] },
|
||||
maxUploadBytes: 1234,
|
||||
});
|
||||
});
|
||||
|
||||
it("removes global path access when the allowed paths field is cleared", () => {
|
||||
expect(configFromDraft({
|
||||
host: "",
|
||||
port: "",
|
||||
allowedHostsMode: "list",
|
||||
allowedHostsText: "",
|
||||
allowedPathsText: "",
|
||||
}, { pathAccess: { allowedPaths: ["/old"] } })).not.toHaveProperty("pathAccess");
|
||||
});
|
||||
|
||||
it("rejects relative external paths before saving", () => {
|
||||
expect(() => configFromDraft({
|
||||
host: "",
|
||||
port: "",
|
||||
allowedHostsMode: "list",
|
||||
allowedHostsText: "",
|
||||
allowedPathsText: "relative/path",
|
||||
})).toThrow("Allowed external paths must be absolute paths or start with ~");
|
||||
});
|
||||
|
||||
it("preserves the spawnSessions flag when saving general settings", () => {
|
||||
const result = configFromDraft({
|
||||
host: "",
|
||||
port: "",
|
||||
allowedHostsMode: "list",
|
||||
allowedHostsText: "",
|
||||
allowedPathsText: "",
|
||||
}, { spawnSessions: true });
|
||||
expect(result.spawnSessions).toBe(true);
|
||||
});
|
||||
|
||||
@@ -5,10 +5,11 @@ export interface ConfigDraft {
|
||||
port: string;
|
||||
allowedHostsMode: "list" | "all";
|
||||
allowedHostsText: string;
|
||||
allowedPathsText: string;
|
||||
}
|
||||
|
||||
export function emptyConfigDraft(): ConfigDraft {
|
||||
return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "" };
|
||||
return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "", allowedPathsText: "" };
|
||||
}
|
||||
|
||||
export function draftFromConfig(config: PiWebConfigValues): ConfigDraft {
|
||||
@@ -17,6 +18,7 @@ export function draftFromConfig(config: PiWebConfigValues): ConfigDraft {
|
||||
port: config.port === undefined ? "" : String(config.port),
|
||||
allowedHostsMode: config.allowedHosts === true ? "all" : "list",
|
||||
allowedHostsText: Array.isArray(config.allowedHosts) ? config.allowedHosts.join("\n") : "",
|
||||
allowedPathsText: config.pathAccess?.allowedPaths?.join("\n") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,6 +26,7 @@ export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValue
|
||||
const config: PiWebConfigValues = {
|
||||
...(baseConfig.shortcuts === undefined ? {} : { shortcuts: baseConfig.shortcuts }),
|
||||
...(baseConfig.plugins === undefined ? {} : { plugins: baseConfig.plugins }),
|
||||
...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }),
|
||||
...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }),
|
||||
...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }),
|
||||
};
|
||||
@@ -36,9 +39,22 @@ export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValue
|
||||
config.port = parsed;
|
||||
}
|
||||
config.allowedHosts = draft.allowedHostsMode === "all" ? true : parseAllowedHostsText(draft.allowedHostsText);
|
||||
const allowedPaths = parseAllowedPathsText(draft.allowedPathsText);
|
||||
if (allowedPaths.length > 0) config.pathAccess = { allowedPaths };
|
||||
return config;
|
||||
}
|
||||
|
||||
function parseAllowedHostsText(value: string): string[] {
|
||||
return value.split(/[\n,]/u).map((host) => host.trim()).filter((host) => host !== "");
|
||||
}
|
||||
|
||||
function parseAllowedPathsText(value: string): string[] {
|
||||
const paths = value.split("\n").map((path) => path.trim()).filter((path) => path !== "");
|
||||
const invalid = paths.find((path) => !isAbsoluteishAllowedPath(path));
|
||||
if (invalid !== undefined) throw new Error(`Allowed external paths must be absolute paths or start with ~: ${invalid}`);
|
||||
return paths;
|
||||
}
|
||||
|
||||
function isAbsoluteishAllowedPath(path: string): boolean {
|
||||
return path === "~" || path.startsWith("~/") || path.startsWith("~\\") || path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:[\\/]/u.test(path);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user