Commit 7f96703b authored by Evan Liu's avatar Evan Liu Committed by aborovova@mozilla.com
Browse files

Bug 1966186 [wpt PR 52503] - Implement Permission Policy & cross-origin check...

Bug 1966186 [wpt PR 52503] - Implement Permission Policy & cross-origin check for on-device Web Speech,

Automatic update from web-platform-tests
Implement Permission Policy & cross-origin check for on-device Web Speech

This CL implements a Permission Policy and cross-origin check for the
availableOnDevice() and installOnDevice() of the Web Speech API. These
are some of the anti-fingerprinting countermeasures described in
go/on-device-web-speech-fingerprinting-mitigations.

Bug: 40286514
Change-Id: I766e35e4a38b7602cf037e17607662791abcad1a
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/6533837


Reviewed-by: default avatarFred Shih <ffred@chromium.org>
Commit-Queue: Evan Liu <evliu@google.com>
Reviewed-by: default avatarAri Chivukula <arichiv@chromium.org>
Reviewed-by: default avatarAndrey Kosyakov <caseq@chromium.org>
Reviewed-by: default avatarDaniel Cheng <dcheng@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1459582}

--

wpt-commits: 215823256aed1bb2b80402cb08b829e0b81636d0
wpt-pr: 52503

Differential Revision: https://phabricator.services.mozilla.com/D250150
parent 4bf03d81
Loading
Loading
Loading
Loading
+138 −2
Original line number Diff line number Diff line
@@ -5,7 +5,8 @@
<script>
promise_test(async (t) => {
  const lang = "en-US";
  window.SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
  window.SpeechRecognition = window.SpeechRecognition ||
    window.webkitSpeechRecognition;

  // Test that it returns a promise.
  const resultPromise = SpeechRecognition.availableOnDevice(lang);
@@ -24,7 +25,8 @@ promise_test(async (t) => {
  assert_true(
    result === "unavailable" || result === "downloadable" ||
    result === "downloading" || result === "available",
    "The resolved value of the availableOnDevice promise should be a valid value."
    "The resolved value of the availableOnDevice promise should be a " +
    "valid value."
  );
}, "SpeechRecognition.availableOnDevice resolves with a string value.");

@@ -44,4 +46,138 @@ promise_test(async (t) => {
    frameSpeechRecognition.availableOnDevice("en-US"),
  );
}, "SpeechRecognition.availableOnDevice rejects in a detached context.");

promise_test(async (t) => {
  const iframe = document.createElement("iframe");
  // This policy should make the on-device speech recognition
  // feature unavailable.
  iframe.setAttribute("allow", "on-device-speech-recognition 'none'");
  document.body.appendChild(iframe);
  t.add_cleanup(() => iframe.remove());

  await new Promise(resolve => {
    if (iframe.contentWindow &&
        iframe.contentWindow.document.readyState === 'complete') {
      resolve();
    } else {
      iframe.onload = resolve;
    }
  });

  const frameWindow = iframe.contentWindow;
  const frameSpeechRecognition = frameWindow.SpeechRecognition ||
    frameWindow.webkitSpeechRecognition;

  assert_true(!!frameSpeechRecognition,
    "SpeechRecognition should exist in iframe.");
  assert_true(!!frameSpeechRecognition.availableOnDevice,
    "availableOnDevice method should exist on SpeechRecognition in iframe.");

  // Call availableOnDevice and expect it to resolve to "unavailable".
  const availabilityStatus =
    await frameSpeechRecognition.availableOnDevice("en-US");
  assert_equals(availabilityStatus, "unavailable",
    "availableOnDevice should resolve to 'unavailable' if " +
    "'on-device-speech-recognition' Permission Policy is 'none'."
  );
}, "SpeechRecognition.availableOnDevice resolves to 'unavailable' if " +
  "'on-device-speech-recognition' Permission Policy is 'none'.");

promise_test(async (t) => {
  const html = `
    <!DOCTYPE html>
    <script>
      window.addEventListener('message', async (event) => {
        // Ensure we only process the message intended to trigger the test.
        if (event.data !== "runTestCallAvailableOnDevice") return;

        try {
          const SpeechRecognition = window.SpeechRecognition ||
                                    window.webkitSpeechRecognition;
          if (!SpeechRecognition || !SpeechRecognition.availableOnDevice) {
            parent.postMessage({
              type: "error", // Use "error" for API not found or other issues.
              name: "NotSupportedError",
              message: "SpeechRecognition.availableOnDevice API not " +
                       "available in iframe"
            }, "*");
            return;
          }

          // Call availableOnDevice and post its resolution.
          const availabilityStatus =
              await SpeechRecognition.availableOnDevice("en-US");
          parent.postMessage(
              { type: "resolution", result: availabilityStatus },
              "*"
          ); // Post the string status
        } catch (err) {
          // Catch any unexpected errors during the API call or message post.
          parent.postMessage({
            type: "error",
            name: err.name,
            message: err.message
          }, "*");
        }
      });
    <\/script>
  `;

  const blob = new Blob([html], { type: "text/html" });
  const blobUrl = URL.createObjectURL(blob);
  // Important: Revoke the blob URL after the test to free up resources.
  t.add_cleanup(() => URL.revokeObjectURL(blobUrl));

  const iframe = document.createElement("iframe");
  iframe.src = blobUrl;
  // Sandboxing with "allow-scripts" is needed for the script inside
  // the iframe to run.
  // The cross-origin nature is primarily due to the blob URL's origin being
  // treated as distinct from the parent page's origin for security
  // purposes.
  iframe.setAttribute("sandbox", "allow-scripts");
  document.body.appendChild(iframe);
  t.add_cleanup(() => iframe.remove());

  await new Promise(resolve => iframe.onload = resolve);

  const testResult = await new Promise((resolve, reject) => {
    const timeoutId = t.step_timeout(() => {
      reject(new Error("Test timed out waiting for message from iframe. " +
                       "Ensure iframe script is correctly posting a message."));
    }, 6000); // 6-second timeout

    window.addEventListener("message", t.step_func((event) => {
      // Basic check to ensure the message is from our iframe.
      if (event.source !== iframe.contentWindow) return;
      clearTimeout(timeoutId);
      resolve(event.data);
    }));

    // Send a distinct message to the iframe to trigger its test logic.
    iframe.contentWindow.postMessage("runTestCallAvailableOnDevice", "*");
  });

  // Check if the iframe's script reported an error (e.g., API not found).
  if (testResult.type === "error") {
    const errorMessage =
        `Iframe reported an error: ${testResult.name} - ` +
        testResult.message;
    assert_unreached(errorMessage);
  }

  assert_equals(
    testResult.type,
    "resolution",
    "The call from the iframe should resolve and post a 'resolution' " +
    "message."
  );
  assert_equals(
    testResult.result, // Expecting the string "unavailable".
    "unavailable",
    "availableOnDevice should resolve to 'unavailable' in a cross-origin " +
    "iframe."
  );
}, "SpeechRecognition.availableOnDevice should resolve to 'unavailable' " +
   "in a cross-origin iframe.");
</script>
+112 −2
Original line number Diff line number Diff line
@@ -154,6 +154,116 @@ promise_test(async (t) => {
      }
    )
  );
}, "SpeechRecognition.installOnDevice rejects in a detached context " +
   "(with user gesture).");
}, "SpeechRecognition.installOnDevice rejects in a detached context.");

promise_test(async (t) => {
  const iframe = document.createElement("iframe");
  iframe.setAttribute("allow",
    "on-device-speech-recognition 'none'");
  document.body.appendChild(iframe);
  t.add_cleanup(() => iframe.remove());

  await new Promise(resolve => {
    if (iframe.contentWindow &&
        iframe.contentWindow.document.readyState === 'complete') {
      resolve();
    } else {
      iframe.onload = resolve;
    }
  });

  const frameWindow = iframe.contentWindow;
  const frameSpeechRecognition = frameWindow.SpeechRecognition ||
    frameWindow.webkitSpeechRecognition;
  const frameDOMException = frameWindow.DOMException;

  assert_true(!!frameSpeechRecognition,
    "SpeechRecognition should exist in iframe.");
  assert_true(!!frameSpeechRecognition.installOnDevice,
    "installOnDevice method should exist on SpeechRecognition in iframe.");

  await promise_rejects_dom(
    t,
    "NotAllowedError",
    frameDOMException,
    frameSpeechRecognition.installOnDevice("en-US"),
    "installOnDevice should reject with NotAllowedError if " +
    "'install-on-device-speech-recognition' Permission Policy is " +
    "disabled."
  );
}, "SpeechRecognition.installOnDevice rejects if " +
  "'install-on-device-speech-recognition' Permission Policy is disabled.");

promise_test(async (t) => {
  const html = `
    <!DOCTYPE html>
    <script>
      window.addEventListener('message', async (event) => {
        try {
          const SpeechRecognition = window.SpeechRecognition ||
                                    window.webkitSpeechRecognition;
          if (!SpeechRecognition || !SpeechRecognition.installOnDevice) {
            parent.postMessage({
              type: "rejection",
              name: "NotSupportedError",
              message: "API not available"
            }, "*");
            return;
          }

          await SpeechRecognition.installOnDevice("en-US");
          parent.postMessage({ type: "resolution", result: "success" }, "*");
        } catch (err) {
          parent.postMessage({
            type: "rejection",
            name: err.name,
            message: err.message
          }, "*");
        }
      });
    <\/script>
  `;

  // Create a cross-origin Blob URL by fetching from remote origin
  const blob = new Blob([html], { type: "text/html" });
  const blobUrl = URL.createObjectURL(blob);

  const iframe = document.createElement("iframe");
  iframe.src = blobUrl;
  iframe.setAttribute("sandbox", "allow-scripts");
  document.body.appendChild(iframe);
  t.add_cleanup(() => iframe.remove());

  await new Promise(resolve => iframe.onload = resolve);

  const testResult = await new Promise((resolve, reject) => {
    const timeoutId = t.step_timeout(() => {
      reject(new Error("Timed out waiting for message from iframe"));
    }, 6000);

    window.addEventListener("message", t.step_func((event) => {
      if (event.source !== iframe.contentWindow) return;
      clearTimeout(timeoutId);
      resolve(event.data);
    }));

    iframe.contentWindow.postMessage("runTest", "*");
  });

  assert_equals(
    testResult.type,
    "rejection",
    "Should reject due to cross-origin restriction"
  );
  assert_equals(
    testResult.name,
    "NotAllowedError",
    "Should reject with NotAllowedError"
  );
  assert_true(
    testResult.message.includes("cross-origin iframe") ||
    testResult.message.includes("cross-site subframe"),
    `Error message should reference cross-origin. Got: "${testResult.message}"`
  );
}, "SpeechRecognition.installOnDevice should reject in a cross-origin iframe.");
</script>