195 lines
6.8 KiB
JavaScript
195 lines
6.8 KiB
JavaScript
|
|
"use strict";
|
||
|
|
|
||
|
|
const { createHash } = require("node:crypto");
|
||
|
|
const path = require("node:path");
|
||
|
|
|
||
|
|
const contractSchema = require("./generated/plugin-contract.schema.json");
|
||
|
|
|
||
|
|
const PLUGIN_PERMISSIONS = new Set(contractSchema.$defs.PluginPermission.enum);
|
||
|
|
const RESOURCE_SCOPED_PERMISSIONS = new Set(
|
||
|
|
contractSchema.$defs.ResourceScopedPermission.enum,
|
||
|
|
);
|
||
|
|
|
||
|
|
function assertPluginPermission(permission) {
|
||
|
|
if (!PLUGIN_PERMISSIONS.has(permission)) {
|
||
|
|
throw new TypeError(`Unknown plugin permission: ${String(permission)}`);
|
||
|
|
}
|
||
|
|
return permission;
|
||
|
|
}
|
||
|
|
|
||
|
|
function canonicalizeNetworkOrigin(value) {
|
||
|
|
if (typeof value !== "string" || value.length < 1 || value.length > 2_048) {
|
||
|
|
throw new TypeError("Plugin network origin is invalid");
|
||
|
|
}
|
||
|
|
let parsed;
|
||
|
|
try { parsed = new URL(value); }
|
||
|
|
catch { throw new TypeError("Plugin network origin is invalid"); }
|
||
|
|
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||
|
|
throw new TypeError("Plugin network access supports only HTTP and HTTPS origins");
|
||
|
|
}
|
||
|
|
if (
|
||
|
|
parsed.username
|
||
|
|
|| parsed.password
|
||
|
|
|| (parsed.pathname !== "" && parsed.pathname !== "/")
|
||
|
|
|| parsed.search
|
||
|
|
|| parsed.hash
|
||
|
|
) throw new TypeError("Plugin network grants must contain an origin without credentials or a path");
|
||
|
|
return parsed.origin;
|
||
|
|
}
|
||
|
|
|
||
|
|
function canonicalizeCompanionResource(value) {
|
||
|
|
if (
|
||
|
|
typeof value !== "string"
|
||
|
|
|| value.length < 5
|
||
|
|
|| value.length > 192
|
||
|
|
|| !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+\.[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z][A-Za-z0-9_-]*)*$/u.test(value)
|
||
|
|
) throw new TypeError("Plugin companion resource is invalid");
|
||
|
|
return value;
|
||
|
|
}
|
||
|
|
|
||
|
|
function canonicalizeFilesystemResource(value) {
|
||
|
|
if (
|
||
|
|
typeof value !== "string"
|
||
|
|
|| value.length < 1
|
||
|
|
|| value.length > 8_192
|
||
|
|
|| value.includes("\0")
|
||
|
|
|| !path.isAbsolute(value)
|
||
|
|
) {
|
||
|
|
throw new TypeError("Plugin filesystem grants require an absolute canonical path");
|
||
|
|
}
|
||
|
|
return path.normalize(value);
|
||
|
|
}
|
||
|
|
|
||
|
|
function canonicalizePermissionResource(permission, resource) {
|
||
|
|
assertPluginPermission(permission);
|
||
|
|
if (resource === "*") return resource;
|
||
|
|
if (permission === "network") return canonicalizeNetworkOrigin(resource);
|
||
|
|
if (permission === "companion.execute") return canonicalizeCompanionResource(resource);
|
||
|
|
if (permission === "filesystem.read" || permission === "filesystem.write") {
|
||
|
|
return canonicalizeFilesystemResource(resource);
|
||
|
|
}
|
||
|
|
if (typeof resource !== "string" || resource.length < 1 || resource.length > 2_048) {
|
||
|
|
throw new TypeError("Plugin permission resource is invalid");
|
||
|
|
}
|
||
|
|
return resource;
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeForFilesystemComparison(value) {
|
||
|
|
const normalized = path.resolve(value);
|
||
|
|
return process.platform === "win32" ? normalized.toLocaleLowerCase("en-US") : normalized;
|
||
|
|
}
|
||
|
|
|
||
|
|
function permissionResourceCovers(
|
||
|
|
permission,
|
||
|
|
grantResource,
|
||
|
|
requestedResource,
|
||
|
|
resourceKind = "exact",
|
||
|
|
requestedResourceKind = "exact",
|
||
|
|
) {
|
||
|
|
if (grantResource === "*") return true;
|
||
|
|
if (permission === "filesystem.read" || permission === "filesystem.write") {
|
||
|
|
if (requestedResourceKind === "directory" && resourceKind !== "directory") return false;
|
||
|
|
const parent = normalizeForFilesystemComparison(grantResource);
|
||
|
|
const candidate = normalizeForFilesystemComparison(requestedResource);
|
||
|
|
if (resourceKind !== "directory") return parent === candidate;
|
||
|
|
const relative = path.relative(parent, candidate);
|
||
|
|
return relative === ""
|
||
|
|
|| (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`));
|
||
|
|
}
|
||
|
|
return grantResource === requestedResource;
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizePermissionDeclarations(manifest) {
|
||
|
|
const declarations = new Map();
|
||
|
|
for (const [required, values] of [
|
||
|
|
[true, manifest?.permissions?.required ?? []],
|
||
|
|
[false, manifest?.permissions?.optional ?? []],
|
||
|
|
]) {
|
||
|
|
for (const value of values) {
|
||
|
|
const permission = assertPluginPermission(typeof value === "string" ? value : value.permission);
|
||
|
|
const resources = typeof value === "string"
|
||
|
|
? []
|
||
|
|
: [...new Set((value.resources ?? []).map((resource) => (
|
||
|
|
canonicalizePermissionResource(permission, resource)
|
||
|
|
)))].sort();
|
||
|
|
if (required && RESOURCE_SCOPED_PERMISSIONS.has(permission)) {
|
||
|
|
if (resources.length === 0) {
|
||
|
|
throw new TypeError(`Required permission ${permission} must declare resources`);
|
||
|
|
}
|
||
|
|
if (resources.includes("*")) {
|
||
|
|
throw new TypeError(`Required permission ${permission} must not use wildcard resources`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
declarations.set(permission, Object.freeze({
|
||
|
|
permission,
|
||
|
|
required,
|
||
|
|
resources: Object.freeze(resources),
|
||
|
|
reason: typeof value === "string" ? "" : value.reason ?? "",
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return declarations;
|
||
|
|
}
|
||
|
|
|
||
|
|
function defaultSecurityPrincipal(manifest, archiveSha256) {
|
||
|
|
if (!manifest || typeof manifest.id !== "string" || typeof manifest.publisher !== "string") {
|
||
|
|
throw new TypeError("Plugin security principal requires manifest identity");
|
||
|
|
}
|
||
|
|
if (
|
||
|
|
archiveSha256 !== undefined
|
||
|
|
&& (typeof archiveSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(archiveSha256))
|
||
|
|
) throw new TypeError("Plugin security principal package digest is invalid");
|
||
|
|
const digest = createHash("sha256")
|
||
|
|
.update(manifest.id)
|
||
|
|
.update("\0")
|
||
|
|
.update(manifest.publisher)
|
||
|
|
.update("\0")
|
||
|
|
.update(archiveSha256 ?? "unbound-development-package")
|
||
|
|
.digest("hex");
|
||
|
|
return `${archiveSha256 ? "unsigned-package" : "unsigned-development"}:${digest}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function assertSecurityPrincipal(value) {
|
||
|
|
if (typeof value !== "string" || value.length < 16 || value.length > 256 || value.includes("\0")) {
|
||
|
|
throw new TypeError("Plugin security principal is invalid");
|
||
|
|
}
|
||
|
|
return value;
|
||
|
|
}
|
||
|
|
|
||
|
|
function permissionDeclarationHash(declaration, securityPrincipal = "legacy:unbound-principal") {
|
||
|
|
return createHash("sha256").update(JSON.stringify({
|
||
|
|
permission: declaration.permission,
|
||
|
|
required: declaration.required,
|
||
|
|
resources: [...declaration.resources].sort(),
|
||
|
|
securityPrincipal: assertSecurityPrincipal(securityPrincipal),
|
||
|
|
})).digest("hex");
|
||
|
|
}
|
||
|
|
|
||
|
|
function declarationAllowsResource(declaration, requestedResource) {
|
||
|
|
if (declaration.resources.length === 0) return true;
|
||
|
|
return declaration.resources.some((resource) => permissionResourceCovers(
|
||
|
|
declaration.permission,
|
||
|
|
resource,
|
||
|
|
requestedResource,
|
||
|
|
declaration.permission === "filesystem.read" || declaration.permission === "filesystem.write"
|
||
|
|
? "directory"
|
||
|
|
: "exact",
|
||
|
|
));
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = {
|
||
|
|
PLUGIN_PERMISSIONS,
|
||
|
|
RESOURCE_SCOPED_PERMISSIONS,
|
||
|
|
assertPluginPermission,
|
||
|
|
assertSecurityPrincipal,
|
||
|
|
canonicalizeCompanionResource,
|
||
|
|
canonicalizeFilesystemResource,
|
||
|
|
canonicalizeNetworkOrigin,
|
||
|
|
canonicalizePermissionResource,
|
||
|
|
declarationAllowsResource,
|
||
|
|
defaultSecurityPrincipal,
|
||
|
|
normalizePermissionDeclarations,
|
||
|
|
permissionDeclarationHash,
|
||
|
|
permissionResourceCovers,
|
||
|
|
};
|