Ctrl K
ComunidadGuías…

electron/app-permissions.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Privileged capabilities (camera, geolocation, USB, HID, serial, MIDI, unguarded screen capture) stay off,
// and the allow-list applies only to the verified renderer origin.

const ALLOWED_APP_PERMISSIONS = new Set([
  "notifications",
  "clipboard-read",
  "clipboard-sanitized-write",
  "fullscreen",
]);

// Opaque origins (data:, about:blank) serialise as "null"; never let two of them match.
function webOrigin(value) {
  if (typeof value !== "string") return null;
  try {
    const origin = new URL(value).origin;
    return origin === "null" ? null : origin;
  } catch {
    return null;
  }
}

/**
 * @param {string} permission
 * @param {string} requestingUrlOrOrigin
 * @param {string} rendererOrigin
 * @param {{ mediaTypes?: string[], mediaType?: string }} [details]
 * @returns {boolean}
 */
export function appPermissionAllowed(permission, requestingUrlOrOrigin, rendererOrigin, details = {}) {
  const requesting = webOrigin(requestingUrlOrOrigin);
  const allowed = webOrigin(rendererOrigin);
  if (!requesting || !allowed || requesting !== allowed) return false;

  // Electron 43 routes getDisplayMedia through "media" with mediaTypes: [] before selecting display media.
  if (permission === "media") {
    if (details?.mediaType !== undefined && details.mediaType !== "audio") return false;
    if (details?.mediaTypes !== undefined) {
      return Array.isArray(details.mediaTypes) && details.mediaTypes.every((type) => type === "audio");
    }
    return details?.mediaType === "audio";
  }

  return ALLOWED_APP_PERMISSIONS.has(permission);
}

function mainFrameMicrophoneAllowed(permission, requestingUrlOrOrigin, trustedOrigin, details) {
  if (permission !== "media" || details?.isMainFrame !== true || details.mediaTypes?.length === 0) return false;
  return appPermissionAllowed("media", requestingUrlOrOrigin, trustedOrigin ?? null, details);
}

/**
 * navigator.clipboard.writeText asks for "clipboard-sanitized-write" within Chromium's transient user
 * activation (~5 s); without one it asks for "clipboard-read", which stays denied here.
 *
 * @param {string} permission
 * @param {string} requestingUrlOrOrigin
 * @param {string | null | undefined} activeRemoteOrigin
 * @param {{ isMainFrame?: boolean }} [details]
 * @returns {boolean}
 */
export function remoteClipboardWriteAllowed(permission, requestingUrlOrOrigin, activeRemoteOrigin, details = {}) {
  if (permission !== "clipboard-sanitized-write") return false;
  if (details?.isMainFrame !== true) return false;
  const requesting = webOrigin(requestingUrlOrOrigin);
  const active = webOrigin(activeRemoteOrigin);
  return Boolean(requesting && active && requesting === active);
}

/**
 * @param {{ rendererOrigin: () => string, mainContents: () => unknown, cloudHomeOrigin: () => string | null,
 *   cloudHomeRestoring?: () => Promise<unknown> | null, activeRemoteOrigin?: () => string | null,
 *   activeRemoteMicrophone?: () => boolean, askRemoteMicrophone?: (origin: string) => Promise<unknown> | null,
 *   homeOrigin?: () => string | null }} context
 *   Every getter is read per request, so a sign-out or server switch withdraws access at once. `homeOrigin`: My Cloud
 *   once the person's bots moved there (home-window.mjs), whose page alone may show notifications besides this
 *   computer's and the active remote server's.
 */
export function appPermissionHandlers({ rendererOrigin, mainContents, cloudHomeOrigin, cloudHomeRestoring = () => null, activeRemoteOrigin = () => null,
  activeRemoteMicrophone = () => false, askRemoteMicrophone = () => null, homeOrigin = () => null }) {
  // Notifications for the main window's main frame on `origin`: My Cloud as home, or the remote server the person
  // switched to (Settings → Notifications there has no browser site settings to fall back on).
  // Fullscreen too, so a chat video's own fullscreen button works there (it still needs the person's click).
  const notifiesAs = (origin, contents, permission, requesting, details) => {
    const page = webOrigin(origin);
    return (permission === "notifications" || permission === "fullscreen") && Boolean(page) && Boolean(contents) &&
      contents === mainContents() && details?.isMainFrame === true && webOrigin(requesting) === page;
  };
  const asksAsCloud = (contents, permission, requesting, details) =>
    Boolean(contents) && contents === mainContents() && mainFrameMicrophoneAllowed(permission, requesting, requesting, details);
  const asksAsRemote = (contents, permission, requesting, details) =>
    Boolean(contents) && contents === mainContents() && mainFrameMicrophoneAllowed(permission, requesting, activeRemoteOrigin(), details);
  const clipboardWrite = (contents, permission, requesting, details) =>
    Boolean(contents) && contents === mainContents() &&
    (remoteClipboardWriteAllowed(permission, requesting, activeRemoteOrigin(), details) ||
      remoteClipboardWriteAllowed(permission, requesting, cloudHomeOrigin(), details));
  const allowed = (contents, permission, requesting, details) =>
    appPermissionAllowed(permission, requesting, rendererOrigin(), details) ||
    (asksAsCloud(contents, permission, requesting, details) && mainFrameMicrophoneAllowed(permission, requesting, cloudHomeOrigin(), details)) ||
    (asksAsRemote(contents, permission, requesting, details) && activeRemoteMicrophone() === true) ||
    clipboardWrite(contents, permission, requesting, details) ||
    notifiesAs(homeOrigin(), contents, permission, requesting, details) ||
    notifiesAs(activeRemoteOrigin(), contents, permission, requesting, details);
  return {
    // Only a request may wait (Electron answers through the callback); the rule runs again after any wait.
    request: (contents, permission, callback, details) => {
      const requesting = details?.requestingUrl ?? contents?.getURL?.() ?? "";
      const decide = () => allowed(contents, permission, requesting, details);
      const settle = (waiting) => Promise.resolve(waiting).catch(() => {});
      const decideOrAsk = () => {
        if (decide()) return true;
        const asking = asksAsRemote(contents, permission, requesting, details) ? askRemoteMicrophone(activeRemoteOrigin()) : null;
        return asking ? settle(asking).then(decide) : false;
      };
      const restoring = !decide() && asksAsCloud(contents, permission, requesting, details) ? cloudHomeRestoring() : null;
      const answer = restoring ? settle(restoring).then(decideOrAsk) : decideOrAsk();
      if (typeof answer === "boolean") return callback(answer);
      void answer.then(callback);
    },
    check: (contents, permission, requestingOrigin, details) =>
      allowed(contents, permission, requestingOrigin || contents?.getURL?.() || "", details),
    pageMicrophone: (event) => {
      const contents = event?.sender;
      const frame = event?.senderFrame;
      const isMainFrame = Boolean(frame) && frame === contents?.mainFrame;
      return allowed(contents, "media", frame?.url ?? "", { isMainFrame, mediaTypes: ["audio"] }) ? "allowed" : "refused";
    },
  };
}

// Both explicit IPC links and window.open use this one policy: a web link, or an email draft the person sends themselves.
export function externalLinkUrl(rawUrl) {
  return typeof rawUrl === "string" && /^\s*mailto:/i.test(rawUrl) ? emailDraftUrl(rawUrl) : externalWebUrl(rawUrl);
}

export function externalWebUrl(rawUrl) {
  if (typeof rawUrl !== "string") throw new Error("A web address is required");
  let url;
  try {
    url = new URL(rawUrl);
  } catch {
    throw new Error("That web address is invalid");
  }
  if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error("Only web links can be opened");
  if (url.username || url.password) throw new Error("Web links must not include user credentials");
  return url.toString();
}

const EMAIL_DRAFT_FIELDS = new Set(["subject", "body"]);

/** Security: one address, and only a subject and a body, so a link can add no recipient and attach no file (some mail
 * clients honour an `attach` field). */
export function emailDraftUrl(rawUrl) {
  let url, address;
  try {
    url = new URL(rawUrl);
    address = decodeURIComponent(url.pathname);
  } catch {
    throw new Error("That email link is invalid");
  }
  if (url.protocol !== "mailto:") throw new Error("Only email links can be drafted");
  if (!/^[^\s@,;]+@[^\s@,;]+$/.test(address)) throw new Error("An email link names one address");
  if ([...url.searchParams.keys()].some(field => !EMAIL_DRAFT_FIELDS.has(field.toLowerCase()))) {
    throw new Error("An email link carries only a subject and a body");
  }
  return url.toString();
}