Fix Firefox account consent for 4.0.6

This commit is contained in:
Anarios
2026-09-07 19:10:02 +02:00
parent e500088e65
commit ee20c7437c
20 changed files with 606 additions and 331 deletions
+12
View File
@@ -0,0 +1,12 @@
# Return YouTube Dislike 4.0.6
What's new since 3.0.0.18:
- **YouTube Shorts support:** see dislike counts and vote on Shorts, with fixes for switching between videos and keeping the buttons in sync.
- **More reliable counts and voting:** fixes for the latest YouTube layouts, stale counts after navigation, and votes that failed to submit.
- **Premium statistics:** explore likes and dislikes over time, compare activity by country, and browse interactive world and US maps.
- **Hide clutter:** keep Like and Dislike visible while moving extra YouTube actions into the More menu.
- **Refreshed controls:** an updated popup, improved display settings, and raw RYD like/dislike totals in the side panel.
- **New: GitHub contributor sign-in**, alongside Patreon access to premium features.
Requires Firefox 140 or newer.
+12 -96
View File
@@ -1,105 +1,21 @@
# AMO 4.0.5 Submission Notes # AMO 4.0.6 Reviewer Notes
## Files to upload 4.0.6 fixes the `authenticationInfo` consent handling rejected in 4.0.5. Account requests require an explicit grant from
`browser.permissions.getAll().data_collection`. Sign-in requests consent from the user's click; denial blocks account
traffic, and revocation clears the session. Dislike counts work without signing in.
- Add-on package: `return-youtube-dislike-firefox-4.0.5.zip` Build the supplied source with Node.js 22.17.0 and npm 10.8.2:
- Source package: `return-youtube-dislike-source-4.0.5.zip`
- Privacy policy: copy `Docs/Privacy Policy` into the AMO listing privacy-policy field.
## Backend deployment prerequisite
Deploy the matching backend entitlement change **before releasing extension 4.0.5** or publishing its privacy policy.
The account flow must request only currently entitled Patreon tier IDs for eligibility, with no membership amount,
charge-date/status, lifetime-payment, or patron-status attributes. Verify that behavior and a fresh reviewer-account
login against the deployed backend. The source changes and these notes do not establish that the live backend has
already been updated.
## Notes to reviewer
Version 4.0.5 addresses rejection reference `a424a6ef-1172-4c6c-8028-22187c194255` and the earlier source-code
review concerns.
The Firefox manifest now uses Firefox's built-in data-collection consent system and requires Firefox 140 or later. It
retains the published add-on ID `{762f9885-5a13-4abd-9c77-433dcd38b8fd}` and declares these required data categories:
- `personallyIdentifyingInfo`: a persistent random installation `userId` is registered with the Return YouTube Dislike
API and is included with vote submissions. Optional sign-in also processes provider account identifiers, name,
profile image, and email when returned by GitHub under this category. Patreon does not request or return email.
- `browsingActivity`: the current YouTube `videoId` identifies the video page being viewed and is sent for dislike
lookups and related analytics.
- `websiteContent`: the current YouTube `videoId` and, when available, visible `likeCount` are sent to the API to obtain
and improve the dislike estimate.
- `websiteActivity`: a user-initiated Like, Dislike, or vote removal is sent as the vote `value` with the `videoId` and
random `userId`.
The optional Patreon/GitHub account feature declares `authenticationInfo`. The matching backend determines Patreon
access from currently entitled tier IDs, without requesting or using membership amounts, charge dates or status,
lifetime payments, or patron status. Firefox requests authentication permission directly from the corresponding
sign-in click. Denying it produces no OAuth, session-verification, or premium Bearer-token traffic. Removing it clears
the account session, tears down active premium analytics, and blocks
later account requests. Delayed login and verification responses cannot restore a revoked session. Core dislike counts
continue to work. Browser sync may synchronize extension settings, identifiers, and account sessions when enabled.
Treating tier IDs as account entitlements covered by account identity and authentication declarations is our data-category
interpretation; Mozilla has not approved this classification. Paid access remains disclosed in the listing. The
`financialAndPaymentInfo` category is omitted because the matching backend no longer requests or processes the financial
and payment attributes used by the earlier implementation. Removing an obsolete financial-data grant alone does not
sign the user out; revoking authentication consent does.
Firefox private-window access is disabled so private browsing data is not retained. The popup no longer makes an
unrelated remote version check or server-status probe, and packaged extension pages no longer request remote fonts.
The supplied source archive contains the lockfile, exact production inputs, and build instructions. From its root,
install Node.js 22.17.0 and npm 10.8.2, then run:
```sh ```sh
node scripts/build-firefox-source.mjs node scripts/build-firefox-source.mjs
``` ```
The generated comparison directory is `Extensions/combined/dist/firefox`. The build script checks the version, Output: `Extensions/combined/dist/firefox`. The source rebuild matches the submitted package byte for byte.
manifest consent contract, required `menu-fixer.js`, bundle size, absence of source-map references, and source-input
receipt. Dependencies are installed only through npm from the npm registry.
## Functional review path To test premium statistics, select **Login with Patreon**, accept the optional consent, and use the account below.
Open a YouTube video to view the statistics. Revoke authentication consent in `about:addons` to verify sign-out.
1. Install in a fresh Firefox 140-or-newer profile and accept the required data categories. ```text
2. Open a public YouTube video and confirm the dislike estimate appears. Login:
3. Open the extension popup. Decline either Patreon or GitHub sign-in consent and confirm the account remains signed Password:
out while ordinary dislike counts continue working. ```
4. Grant the optional authentication permission and sign in with the supplied reviewer account. Exercise the premium
analytics panel using that account's active premium access.
5. Revoke authentication data under `about:addons` > Return YouTube Dislike > Permissions and data. Confirm
the account session ends and no premium panel appears on subsequent navigation or after restarting Firefox.
## Listing and reviewer access
Add this disclosure to the listing description:
> Return YouTube Dislike sends the current YouTube video ID and visible like count to its API to display dislike
> estimates. Optional vote submissions include a persistent random installation ID and your selected vote. Optional
> Patreon/GitHub sign-in processes account and membership information. Premium analytics has eligibility requirements,
> including paid Patreon membership tiers. Core dislike counts are free. Firefox asks for required data consent during installation or update and
> requests optional account-data consent when you sign in.
Before submission, deploy the backend entitlement change and complimentary-access grant, then verify a fresh login with the dedicated
Patreon test account. Supply that account's login details in AMO's **Notes for Reviewers**, as described in the
[submission guide](https://extensionworkshop.com/documentation/publish/submitting-an-add-on/). Mozilla requires
reviewer access to account-only functionality. Do not place credentials in this file or in the source archive.
Confirm the listing discloses paid functionality and includes the updated privacy policy.
Include these instructions with the login details:
> This is a dedicated Patreon test account with complimentary premium access granted by our backend. No paid
> subscription is needed. In the extension popup, choose Log in with Patreon, accept the optional account-data
> permission prompt, and complete Patreon authorization using the supplied test account. Open a YouTube video and
> its premium analytics. Complimentary access is available through October 5, 2026 (UTC); contact us if the review
> requires an extension.
## Policy references
- [Built-in data consent and data categories](https://extensionworkshop.com/documentation/develop/firefox-builtin-data-consent/)
- [Submission and data-transmission requirements](https://extensionworkshop.com/documentation/publish/add-on-policies/)
- [Source-code submission](https://extensionworkshop.com/documentation/publish/source-code-submission/)
Temporary developer installs silently grant required permissions and do not prove the installation/update consent
prompt. Test that prompt using a packaged install in a disposable Firefox Developer Edition or Nightly profile.
+2 -2
View File
@@ -1,8 +1,8 @@
# Return YouTube Dislike Privacy Policy # Return YouTube Dislike Privacy Policy
Last updated: September 5, 2026 Last updated: September 7, 2026
This policy describes version 4.0.5 and its matching backend release. This policy describes version 4.0.6 and its matching backend release.
Return YouTube Dislike transmits only the data needed to provide its dislike-count, voting, abuse-prevention, and Return YouTube Dislike transmits only the data needed to provide its dislike-count, voting, abuse-prevention, and
optional premium features. It does not sell personal data or use it for advertising. optional premium features. It does not sell personal data or use it for advertising.
+2 -2
View File
@@ -21,7 +21,7 @@ associate submitted votes with the same extension installation.
For dislike-count requests: the current video ID and, when available, the visible like count. For registration and vote For dislike-count requests: the current video ID and, when available, the visible like count. For registration and vote
submission: the random extension user ID, video ID, selected vote, timestamp, and network address used for abuse submission: the random extension user ID, video ID, selected vote, timestamp, and network address used for abuse
controls. Optional premium features use the account ID, name, email address, profile image, membership information, and controls. Optional premium features use the account ID, name, email address, profile image, membership information, and
service session token returned by a Patreon or GitHub sign-in initiated by the user. For version 4.0.5 and its matching service session token returned by a Patreon or GitHub sign-in initiated by the user. For version 4.0.6 and its matching
backend release, Patreon access is determined from currently entitled tier IDs rather than financial information. backend release, Patreon access is determined from currently entitled tier IDs rather than financial information.
### How is my IP stored? ### How is my IP stored?
@@ -33,7 +33,7 @@ abuse prevention and aggregate analytics. It is not sold or used for advertising
Premium analytics sign-in is optional and starts only after the user chooses Patreon or GitHub login in the extension. Premium analytics sign-in is optional and starts only after the user chooses Patreon or GitHub login in the extension.
The OAuth flow returns account details and a Return YouTube Dislike session token used to authenticate premium API The OAuth flow returns account details and a Return YouTube Dislike session token used to authenticate premium API
requests. In version 4.0.5 and its matching backend release, Patreon eligibility uses currently entitled tier IDs; requests. In version 4.0.6 and its matching backend release, Patreon eligibility uses currently entitled tier IDs;
the service does not request or use membership amounts, charge dates or status, lifetime payments, or patron status. the service does not request or use membership amounts, charge dates or status, lifetime payments, or patron status.
Firefox asks for separate authentication-data consent before account traffic begins. Signing out removes Firefox asks for separate authentication-data consent before account traffic begins. Signing out removes
the stored account details and session from the extension. Revoking Firefox's authentication-data permission also clears the the stored account details and session from the extension. Revoking Firefox's authentication-data permission also clears the
@@ -103,7 +103,7 @@ describe("generated userscript artifact", () => {
config.output = { ...config.output, path: temporaryDirectory }; config.output = { ...config.output, path: temporaryDirectory };
await compile(config); await compile(config);
const rebuilt = fs.readFileSync(path.join(temporaryDirectory, config.output.filename), "utf8"); const rebuilt = fs.readFileSync(path.join(temporaryDirectory, config.output.filename), "utf8");
expect(rebuilt).toBe(artifact); expect(rebuilt.replace(/\r\n/g, "\n")).toBe(artifact.replace(/\r\n/g, "\n"));
} finally { } finally {
expectFileUnchanged(receiptPath, receiptBefore); expectFileUnchanged(receiptPath, receiptBefore);
fs.rmSync(temporaryDirectory, { recursive: true, force: true }); fs.rmSync(temporaryDirectory, { recursive: true, force: true });
+51 -13
View File
@@ -2,13 +2,6 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
jest.mock("./src/data-collection-permissions", () => ({
hasAuthenticationDataPermission: jest.fn(),
onAuthenticationDataPermissionRemoved: jest.fn(),
requestAuthenticationDataPermission: jest.fn(),
usesFirefoxDataCollectionConsent: jest.fn(),
}));
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0));
const cachedUser = { fullName: "Test User", imageUrl: "https://example.org/avatar", membershipTier: "none" }; const cachedUser = { fullName: "Test User", imageUrl: "https://example.org/avatar", membershipTier: "none" };
@@ -21,14 +14,19 @@ describe("popup account consent lifecycle", () => {
jest.resetModules(); jest.resetModules();
consent = true; consent = true;
document.documentElement.innerHTML = fs.readFileSync(path.join(__dirname, "popup.html"), "utf8"); document.documentElement.innerHTML = fs.readFileSync(path.join(__dirname, "popup.html"), "utf8");
const permissions = require("./src/data-collection-permissions");
permissions.usesFirefoxDataCollectionConsent.mockReturnValue(true);
permissions.hasAuthenticationDataPermission.mockImplementation(() => Promise.resolve(consent));
permissions.requestAuthenticationDataPermission.mockImplementation(() => Promise.resolve(consent));
permissions.onAuthenticationDataPermissionRemoved.mockImplementation((listener) => (removeConsent = listener));
global.chrome = { global.chrome = {
i18n: { getMessage: () => "" }, i18n: { getMessage: () => "" },
runtime: { getManifest: () => ({ version: "4.0.5" }), sendMessage: jest.fn() }, runtime: { getManifest: () => require("./manifest-firefox.json"), sendMessage: jest.fn() },
permissions: {
getAll: jest.fn(() => Promise.resolve({ data_collection: consent ? ["authenticationInfo"] : [] })),
contains: jest.fn().mockResolvedValue(true),
request: jest.fn(() => Promise.resolve(consent)),
onRemoved: {
addListener: (listener) => {
removeConsent = () => listener({ data_collection: ["authenticationInfo"] });
},
},
},
identity: { getRedirectURL: jest.fn() }, identity: { getRedirectURL: jest.fn() },
storage: { storage: {
sync: { sync: {
@@ -41,6 +39,7 @@ describe("popup account consent lifecycle", () => {
onChanged: { addListener: jest.fn() }, onChanged: { addListener: jest.fn() },
}, },
}; };
global.browser = global.chrome;
global.fetch = jest.fn(); global.fetch = jest.fn();
require("./popup"); require("./popup");
}); });
@@ -48,6 +47,7 @@ describe("popup account consent lifecycle", () => {
afterEach(() => { afterEach(() => {
jest.restoreAllMocks(); jest.restoreAllMocks();
delete global.chrome; delete global.chrome;
delete global.browser;
delete global.fetch; delete global.fetch;
}); });
@@ -82,6 +82,44 @@ describe("popup account consent lifecycle", () => {
expect(document.getElementById("patreon-user-avatar").hasAttribute("src")).toBe(false); expect(document.getElementById("patreon-user-avatar").hasAttribute("src")).toBe(false);
} }
test.each([[], undefined])("does not verify or display cached account data without a grant: %j", async (granted) => {
chrome.permissions.getAll.mockResolvedValue({ permissions: ["identity"], data_collection: granted });
readCachedSession({ patreonUser: cachedUser, patreonSessionToken: "test-token" });
await flushPromises();
expect(fetch).not.toHaveBeenCalled();
expect(chrome.storage.sync.remove).toHaveBeenCalledWith([
"patreonAuthenticated",
"patreonUser",
"patreonSessionToken",
]);
expectLoggedOut();
});
test.each(["patreon-login-btn", "github-login-btn"])(
"does not start %s when Firefox denies authentication consent",
async (button) => {
consent = false;
jest.spyOn(window, "alert").mockImplementation(() => {});
document.getElementById(button).click();
expect(chrome.permissions.request).toHaveBeenCalledWith({ data_collection: ["authenticationInfo"] });
await flushPromises();
expect(chrome.runtime.sendMessage).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
},
);
test.each(["patreon-login-btn", "github-login-btn"])(
"does not start %s after a request succeeds without a recorded grant",
async (button) => {
chrome.permissions.getAll.mockResolvedValue({ permissions: ["identity"], data_collection: [] });
jest.spyOn(window, "alert").mockImplementation(() => {});
document.getElementById(button).click();
await flushPromises();
expect(chrome.runtime.sendMessage).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
},
);
test("does not verify or show a cached session whose storage read completes after revocation", async () => { test("does not verify or show a cached session whose storage read completes after revocation", async () => {
revoke(); revoke();
readCachedSession({ patreonUser: cachedUser, patreonSessionToken: "test-token" }); readCachedSession({ patreonUser: cachedUser, patreonSessionToken: "test-token" });
+23 -10
View File
@@ -1,9 +1,4 @@
jest.mock("../common/vote-client", () => ({ createVoteClient: jest.fn() })); jest.mock("../common/vote-client", () => ({ createVoteClient: jest.fn() }));
jest.mock("./src/data-collection-permissions", () => ({
hasAuthenticationDataPermission: jest.fn(),
onAuthenticationDataPermissionRemoved: jest.fn(),
usesFirefoxDataCollectionConsent: jest.fn(),
}));
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0));
const startData = () => { const startData = () => {
@@ -45,18 +40,23 @@ describe("background account consent lifecycle", () => {
}, },
}, },
}); });
const permissions = require("./src/data-collection-permissions");
permissions.usesFirefoxDataCollectionConsent.mockReturnValue(true);
permissions.hasAuthenticationDataPermission.mockImplementation(() => Promise.resolve(consent));
permissions.onAuthenticationDataPermissionRemoved.mockImplementation((listener) => (removeConsent = listener));
require("../common/vote-client").createVoteClient.mockReturnValue({ ensureRegistered: () => Promise.resolve({}) }); require("../common/vote-client").createVoteClient.mockReturnValue({ ensureRegistered: () => Promise.resolve({}) });
global.__RYD_LIVE_TEST_BUILD__ = false; global.__RYD_LIVE_TEST_BUILD__ = false;
global.chrome = { global.chrome = {
runtime: { runtime: {
getManifest: () => ({ version: "4.0.5" }), getManifest: () => require("./manifest-firefox.json"),
onMessage: { addListener: (listener) => (messageListener = listener) }, onMessage: { addListener: (listener) => (messageListener = listener) },
onInstalled: { addListener: jest.fn() }, onInstalled: { addListener: jest.fn() },
}, },
permissions: {
getAll: jest.fn(() => Promise.resolve({ data_collection: consent ? ["authenticationInfo"] : [] })),
contains: jest.fn().mockResolvedValue(true),
onRemoved: {
addListener: (listener) => {
removeConsent = () => listener({ data_collection: ["authenticationInfo"] });
},
},
},
identity: { identity: {
getRedirectURL: () => "https://extension.example/callback", getRedirectURL: () => "https://extension.example/callback",
launchWebAuthFlow: jest.fn(() => Promise.resolve("https://extension.example/callback?code=test&state=test")), launchWebAuthFlow: jest.fn(() => Promise.resolve("https://extension.example/callback?code=test&state=test")),
@@ -108,6 +108,19 @@ describe("background account consent lifecycle", () => {
}, },
); );
test.each(["patreon_oauth_login", "github_oauth_login"])(
"%s sends nothing when Firefox does not expose built-in data consent",
async (message) => {
chrome.permissions.getAll.mockResolvedValue({ permissions: ["identity"], origins: [] });
const response = jest.fn();
messageListener({ message }, {}, response);
await flushPromises();
expect(fetch).not.toHaveBeenCalled();
expect(chrome.identity.launchWebAuthFlow).not.toHaveBeenCalled();
expect(response).toHaveBeenCalledWith(expect.objectContaining({ success: false }));
},
);
test.each(["patreon_oauth_login", "github_oauth_login"])( test.each(["patreon_oauth_login", "github_oauth_login"])(
"%s does not open OAuth after consent is revoked during startup", "%s does not open OAuth after consent is revoked during startup",
async (message) => { async (message) => {
@@ -21,18 +21,6 @@ function usesFirefoxDataCollectionConsent() {
return Boolean(manifest?.browser_specific_settings?.gecko?.data_collection_permissions); return Boolean(manifest?.browser_specific_settings?.gecko?.data_collection_permissions);
} }
function callFirefoxPermissionMethod(methodName) {
const permissions = getPermissionsApi();
const method = permissions?.[methodName];
if (typeof method !== "function") return Promise.resolve(false);
try {
return Promise.resolve(method.call(permissions, AUTHENTICATION_DATA_DESCRIPTOR)).then(Boolean, () => false);
} catch (_) {
return Promise.resolve(false);
}
}
function queryBackgroundForAuthenticationDataPermission() { function queryBackgroundForAuthenticationDataPermission() {
const runtime = getRuntimeApi(); const runtime = getRuntimeApi();
if (typeof runtime?.sendMessage !== "function") return Promise.resolve(false); if (typeof runtime?.sendMessage !== "function") return Promise.resolve(false);
@@ -64,8 +52,22 @@ function queryBackgroundForAuthenticationDataPermission() {
function hasAuthenticationDataPermission({ queryBackground = true } = {}) { function hasAuthenticationDataPermission({ queryBackground = true } = {}) {
if (!usesFirefoxDataCollectionConsent()) return Promise.resolve(true); if (!usesFirefoxDataCollectionConsent()) return Promise.resolve(true);
if (typeof getPermissionsApi()?.contains === "function") return callFirefoxPermissionMethod("contains"); const permissions = getPermissionsApi();
return queryBackground ? queryBackgroundForAuthenticationDataPermission() : Promise.resolve(false); if (!permissions) {
return queryBackground ? queryBackgroundForAuthenticationDataPermission() : Promise.resolve(false);
}
if (typeof permissions.getAll !== "function") return Promise.resolve(false);
try {
return Promise.resolve(permissions.getAll()).then(
(granted) =>
Array.isArray(granted?.data_collection) &&
ACCOUNT_DATA_PERMISSIONS.every((permission) => granted.data_collection.includes(permission)),
() => false,
);
} catch (_) {
return Promise.resolve(false);
}
} }
function requestAuthenticationDataPermission() { function requestAuthenticationDataPermission() {
@@ -73,7 +75,17 @@ function requestAuthenticationDataPermission() {
// Keep this direct request as the first operation in the login click stack. // Keep this direct request as the first operation in the login click stack.
// Firefox requires optional data-collection consent requests to originate from a user gesture. // Firefox requires optional data-collection consent requests to originate from a user gesture.
return callFirefoxPermissionMethod("request"); const permissions = getPermissionsApi();
if (typeof permissions?.request !== "function") return Promise.resolve(false);
try {
return Promise.resolve(permissions.request(AUTHENTICATION_DATA_DESCRIPTOR)).then(
(granted) => granted === true && hasAuthenticationDataPermission({ queryBackground: false }),
() => false,
);
} catch (_) {
return Promise.resolve(false);
}
} }
function authenticationDataPermissionWasRemoved(removedPermissions) { function authenticationDataPermissionWasRemoved(removedPermissions) {
@@ -31,7 +31,7 @@ describe("Firefox data-collection permissions", () => {
}); });
global.browser = { global.browser = {
runtime: { getManifest: () => firefoxManifest }, runtime: { getManifest: () => firefoxManifest },
permissions: { request }, permissions: { request, getAll: jest.fn().mockResolvedValue({ data_collection: ["authenticationInfo"] }) },
}; };
const result = requestAuthenticationDataPermission(); const result = requestAuthenticationDataPermission();
@@ -42,18 +42,76 @@ describe("Firefox data-collection permissions", () => {
}); });
it("checks the existing Firefox grant without requesting it", async () => { it("checks the existing Firefox grant without requesting it", async () => {
const contains = jest.fn(() => Promise.resolve(false)); const getAll = jest.fn().mockResolvedValue({ permissions: ["identity"], origins: [], data_collection: [] });
const contains = jest.fn().mockResolvedValue(true);
const request = jest.fn(); const request = jest.fn();
global.browser = { global.browser = {
runtime: { getManifest: () => firefoxManifest }, runtime: { getManifest: () => firefoxManifest },
permissions: { contains, request }, permissions: { getAll, contains, request },
}; };
await expect(hasAuthenticationDataPermission()).resolves.toBe(false); await expect(hasAuthenticationDataPermission()).resolves.toBe(false);
expect(contains).toHaveBeenCalledWith({ data_collection: ["authenticationInfo"] }); expect(getAll).toHaveBeenCalledWith();
expect(contains).not.toHaveBeenCalled();
expect(request).not.toHaveBeenCalled(); expect(request).not.toHaveBeenCalled();
}); });
it.each([{}, { data_collection: [] }, { data_collection: ["financialAndPaymentInfo"] }])(
"does not accept a successful request without a recorded authentication grant: %j",
async (grantedPermissions) => {
global.browser = {
runtime: { getManifest: () => firefoxManifest },
permissions: {
request: jest.fn().mockResolvedValue(true),
getAll: jest.fn().mockResolvedValue(grantedPermissions),
contains: jest.fn().mockResolvedValue(true),
},
};
await expect(requestAuthenticationDataPermission()).resolves.toBe(false);
},
);
it("does not let an existing grant override the user's denied request", async () => {
global.browser = {
runtime: { getManifest: () => firefoxManifest },
permissions: {
request: jest.fn().mockResolvedValue(false),
getAll: jest.fn().mockResolvedValue({ data_collection: ["authenticationInfo"] }),
},
};
await expect(requestAuthenticationDataPermission()).resolves.toBe(false);
expect(browser.permissions.getAll).not.toHaveBeenCalled();
});
it("reads the current grant again after it is revoked without a cached result", async () => {
const getAll = jest
.fn()
.mockResolvedValueOnce({ data_collection: ["authenticationInfo"] })
.mockResolvedValueOnce({ data_collection: [] });
global.browser = {
runtime: { getManifest: () => firefoxManifest },
permissions: { getAll, contains: jest.fn().mockResolvedValue(true) },
};
await expect(hasAuthenticationDataPermission()).resolves.toBe(true);
await expect(hasAuthenticationDataPermission()).resolves.toBe(false);
});
it("fails closed when getAll throws synchronously", async () => {
global.browser = {
runtime: { getManifest: () => firefoxManifest },
permissions: {
getAll: () => {
throw new Error("unavailable");
},
},
};
await expect(hasAuthenticationDataPermission()).resolves.toBe(false);
});
it("leaves Chrome behavior unchanged", async () => { it("leaves Chrome behavior unchanged", async () => {
const request = jest.fn(); const request = jest.fn();
global.chrome = { global.chrome = {
@@ -78,19 +136,20 @@ describe("Firefox data-collection permissions", () => {
it.each([ it.each([
{ granted: ["authenticationInfo"], expected: true }, { granted: ["authenticationInfo"], expected: true },
{ granted: ["financialAndPaymentInfo"], expected: false }, { granted: ["financialAndPaymentInfo"], expected: false },
])( { granted: [], expected: false },
"checks authentication consent independently of obsolete financial grants: $granted", { granted: undefined, expected: false },
async ({ granted, expected }) => { { granted: null, expected: false },
global.browser = { { granted: "authenticationInfo", expected: false },
runtime: { getManifest: () => firefoxManifest }, ])("requires an explicit authentication grant in getAll: $granted", async ({ granted, expected }) => {
permissions: { global.browser = {
contains: ({ data_collection }) => runtime: { getManifest: () => firefoxManifest },
Promise.resolve(data_collection.every((permission) => granted.includes(permission))), permissions: {
}, contains: jest.fn().mockResolvedValue(true),
}; getAll: jest.fn().mockResolvedValue({ permissions: ["identity"], origins: [], data_collection: granted }),
await expect(hasAuthenticationDataPermission()).resolves.toBe(expected); },
}, };
); await expect(hasAuthenticationDataPermission()).resolves.toBe(expected);
});
it("does not end the account session when only an obsolete financial grant is removed", () => { it("does not end the account session when only an obsolete financial grant is removed", () => {
let removedListener; let removedListener;
@@ -124,6 +183,30 @@ describe("Firefox data-collection permissions", () => {
await expect(hasAuthenticationDataPermission({ queryBackground: false })).resolves.toBe(false); await expect(hasAuthenticationDataPermission({ queryBackground: false })).resolves.toBe(false);
}); });
it("does not fall back to contains or the background when getAll is unavailable", async () => {
const sendMessage = jest.fn().mockResolvedValue({ granted: true });
const contains = jest.fn().mockResolvedValue(true);
global.browser = {
runtime: { getManifest: () => firefoxManifest, sendMessage },
permissions: { contains },
};
await expect(hasAuthenticationDataPermission()).resolves.toBe(false);
expect(contains).not.toHaveBeenCalled();
expect(sendMessage).not.toHaveBeenCalled();
});
it("does not fall back to a positive background result after getAll fails", async () => {
const sendMessage = jest.fn().mockResolvedValue({ granted: true });
global.browser = {
runtime: { getManifest: () => firefoxManifest, sendMessage },
permissions: { getAll: jest.fn().mockRejectedValue(new Error("unavailable")) },
};
await expect(hasAuthenticationDataPermission()).resolves.toBe(false);
expect(sendMessage).not.toHaveBeenCalled();
});
it("fails closed on permission API and background failures", async () => { it("fails closed on permission API and background failures", async () => {
global.browser = { global.browser = {
runtime: { getManifest: () => firefoxManifest, sendMessage: () => Promise.reject(new Error("unavailable")) }, runtime: { getManifest: () => firefoxManifest, sendMessage: () => Promise.reject(new Error("unavailable")) },
@@ -131,7 +214,7 @@ describe("Firefox data-collection permissions", () => {
await expect(hasAuthenticationDataPermission()).resolves.toBe(false); await expect(hasAuthenticationDataPermission()).resolves.toBe(false);
await expect(requestAuthenticationDataPermission()).resolves.toBe(false); await expect(requestAuthenticationDataPermission()).resolves.toBe(false);
global.browser.permissions = { global.browser.permissions = {
contains: () => Promise.reject(new Error("unavailable")), getAll: () => Promise.reject(new Error("unavailable")),
request: () => { request: () => {
throw new Error("outside gesture"); throw new Error("outside gesture");
}, },
@@ -255,6 +255,23 @@ describe("premiumAnalytics", () => {
expect(analyticsState.sessionActive).toBe(false); expect(analyticsState.sessionActive).toBe(false);
}); });
it.each([[], undefined])("does not send a bearer token without an explicit Firefox grant: %j", async (granted) => {
const actualConsent = jest.requireActual("../data-collection-permissions");
chrome.runtime = { getManifest: () => require("../../manifest-firefox.json") };
chrome.permissions = {
contains: jest.fn().mockResolvedValue(true),
getAll: jest.fn().mockResolvedValue({ permissions: ["identity"], data_collection: granted }),
};
mockUsesFirefoxDataCollectionConsent.mockImplementationOnce(actualConsent.usesFirefoxDataCollectionConsent);
mockHasAuthenticationDataPermission.mockImplementationOnce(actualConsent.hasAuthenticationDataPermission);
await requestAnalytics();
expect(fetch).not.toHaveBeenCalled();
expect(analyticsState.sessionToken).toBeNull();
expect(analyticsState.sessionActive).toBe(false);
});
it("ignores an analytics response from a session that was signed out", async () => { it("ignores an analytics response from a session that was signed out", async () => {
let completeRequest; let completeRequest;
const response = await fetch(); const response = await fetch();
+292 -135
View File
@@ -9,6 +9,7 @@ const crypto = require("node:crypto");
// Run after the production build: node Extensions/e2e/firefox-consent-smoke.js // Run after the production build: node Extensions/e2e/firefox-consent-smoke.js
// RYD_FIREFOX_BINARY selects a portable browser. RYD_FIREFOX_PACKAGED=1 requires // RYD_FIREFOX_BINARY selects a portable browser. RYD_FIREFOX_PACKAGED=1 requires
// Developer Edition or Nightly and enables unsigned packages only in the owned profile. // Developer Edition or Nightly and enables unsigned packages only in the owned profile.
// RYD_FIREFOX_DATA_CONSENT_DISABLED=1 verifies fail-closed behavior when the native consent API is disabled.
// RYD_FIREFOX_CHANGELOG_LIFECYCLE=1 observes temporary install/reload events. // RYD_FIREFOX_CHANGELOG_LIFECYCLE=1 observes temporary install/reload events.
// RYD_FIREFOX_CHANGELOG_EXPECT_IMMEDIATE=1 also asserts immediate reload display and pending-state recovery. // RYD_FIREFOX_CHANGELOG_EXPECT_IMMEDIATE=1 also asserts immediate reload display and pending-state recovery.
const ROOT = path.resolve(__dirname, "../.."); const ROOT = path.resolve(__dirname, "../..");
@@ -114,8 +115,13 @@ async function run() {
); );
const firefox = process.env.RYD_FIREFOX_BINARY || "C:\\Program Files\\Mozilla Firefox\\firefox.exe"; const firefox = process.env.RYD_FIREFOX_BINARY || "C:\\Program Files\\Mozilla Firefox\\firefox.exe";
const packaged = process.env.RYD_FIREFOX_PACKAGED === "1"; const packaged = process.env.RYD_FIREFOX_PACKAGED === "1";
const consentDisabled = process.env.RYD_FIREFOX_DATA_CONSENT_DISABLED === "1";
const changelogLifecycle = process.env.RYD_FIREFOX_CHANGELOG_LIFECYCLE === "1"; const changelogLifecycle = process.env.RYD_FIREFOX_CHANGELOG_LIFECYCLE === "1";
assert(!(packaged && changelogLifecycle), "Changelog lifecycle reproduction requires a temporary addon"); assert(!(packaged && changelogLifecycle), "Changelog lifecycle reproduction requires a temporary addon");
assert(
!(consentDisabled && (packaged || changelogLifecycle)),
"Disabled-consent validation requires an ordinary temporary addon",
);
const evidence = path.join(ROOT, "test-results", `firefox-consent-${new Date().toISOString().replace(/[:.]/g, "-")}`); const evidence = path.join(ROOT, "test-results", `firefox-consent-${new Date().toISOString().replace(/[:.]/g, "-")}`);
const profile = path.join(evidence, "profile"); const profile = path.join(evidence, "profile");
const derived = path.join(evidence, "extension"); const derived = path.join(evidence, "extension");
@@ -129,6 +135,8 @@ async function run() {
artifact, artifact,
version: manifest.version, version: manifest.version,
scenarios: [], scenarios: [],
permissionStates: [],
consentDisabled,
screenshots: [], screenshots: [],
requests, requests,
blocked, blocked,
@@ -140,7 +148,9 @@ async function run() {
"Temporary installation grants required categories without showing the install/update consent prompt.", "Temporary installation grants required categories without showing the install/update consent prompt.",
"Native arrow-panel text is captured from Firefox UI; headless screenshots do not capture the separate native panel.", "Native arrow-panel text is captured from Firefox UI; headless screenshots do not capture the separate native panel.",
]; ];
const server = http.createServer((request, response) => { let githubChallenge;
let githubRedirectUri;
const server = http.createServer(async (request, response) => {
const url = new URL(request.url, `http://${request.headers.host}`); const url = new URL(request.url, `http://${request.headers.host}`);
requests.push({ method: request.method, path: url.pathname }); requests.push({ method: request.method, path: url.pathname });
response.setHeader("Access-Control-Allow-Origin", "*"); response.setHeader("Access-Control-Allow-Origin", "*");
@@ -153,7 +163,23 @@ async function run() {
request.method === "POST" ? "true" : JSON.stringify({ challenge: "AAAAAAAAAAAAAAAAAAAAAA==", difficulty: 0 }), request.method === "POST" ? "true" : JSON.stringify({ challenge: "AAAAAAAAAAAAAAAAAAAAAA==", difficulty: 0 }),
); );
else if (url.pathname === "/puzzle/registration/confirm") response.end("true"); else if (url.pathname === "/puzzle/registration/confirm") response.end("true");
else if (url.pathname.endsWith("/login")) { else if (url.pathname === "/api/auth/github/login") {
githubChallenge = url.searchParams.get("codeChallenge");
githubRedirectUri = url.searchParams.get("redirectUri");
const params = new URLSearchParams({
state: "fixture-state",
redirect_uri: githubRedirectUri,
code_challenge: githubChallenge,
code_challenge_method: "S256",
});
response.end(
JSON.stringify({
authUrl: `https://github.com/login/oauth/authorize?${params}`,
state: "fixture-state",
redirectUri: githubRedirectUri,
}),
);
} else if (url.pathname.endsWith("/login")) {
response.end( response.end(
JSON.stringify({ JSON.stringify({
authUrl: `${origin}/oauth-authorize?redirectUri=${encodeURIComponent(url.searchParams.get("redirectUri"))}`, authUrl: `${origin}/oauth-authorize?redirectUri=${encodeURIComponent(url.searchParams.get("redirectUri"))}`,
@@ -166,6 +192,22 @@ async function run() {
}); });
response.end(); response.end();
} else if (url.pathname.endsWith("/exchange")) { } else if (url.pathname.endsWith("/exchange")) {
if (url.pathname === "/api/auth/github/exchange") {
let body = "";
for await (const chunk of request) body += chunk;
const data = JSON.parse(body);
result.githubPkceVerified =
typeof data.codeVerifier === "string" &&
crypto.createHash("sha256").update(data.codeVerifier).digest("base64url") === githubChallenge &&
data.state === "fixture-state" &&
data.code === "fixture-code" &&
data.redirectUri === githubRedirectUri;
if (!result.githubPkceVerified) {
response.writeHead(400);
response.end(JSON.stringify({ error: "github_invalid_state" }));
return;
}
}
response.end( response.end(
JSON.stringify({ JSON.stringify({
success: true, success: true,
@@ -207,9 +249,33 @@ async function run() {
source.includes("https://returnyoutubedislikeapi.com"), source.includes("https://returnyoutubedislikeapi.com"),
`${filename} must contain its production API origin`, `${filename} must contain its production API origin`,
); );
await fs.writeFile(file, source.replaceAll("https://returnyoutubedislikeapi.com", origin)); let testSource = source.replaceAll("https://returnyoutubedislikeapi.com", origin);
if (filename === "ryd.background.js") {
// Keep the generated OAuth URL/PKCE validation and native identity flow.
// Route only the external provider navigation to the owned loopback fixture.
const providerBoundary = `(() => {
const nativeLaunch = browser.identity.launchWebAuthFlow.bind(browser.identity);
browser.identity.launchWebAuthFlow = (details) => {
const url = new URL(details.url);
if (url.origin === "https://github.com" && url.pathname === "/login/oauth/authorize") {
const fixture = new URL(${JSON.stringify(origin)} + "/oauth-authorize");
fixture.searchParams.set("redirectUri", url.searchParams.get("redirect_uri"));
fixture.searchParams.set("state", url.searchParams.get("state"));
return nativeLaunch({...details, url:fixture.href});
}
return nativeLaunch(details);
};
})();\n`;
testSource = providerBoundary + testSource;
}
await fs.writeFile(file, testSource);
} }
result.sourceHashes = sourceHashes; result.sourceHashes = sourceHashes;
result.derivation = [
"Replace the production API origin with the owned loopback origin in three generated bundles.",
"Add the loopback host permission only to the owned test copy.",
"Route native identity navigation for the validated GitHub authorize URL to a loopback provider; retain generated URL/state/PKCE checks and the native identity flow.",
];
if (changelogLifecycle) if (changelogLifecycle)
await require("./firefox-changelog-lifecycle").instrumentChangelogLifecycle({ derived, manifest, result }); await require("./firefox-changelog-lifecycle").instrumentChangelogLifecycle({ derived, manifest, result });
manifest.permissions.push(`${origin}/*`); manifest.permissions.push(`${origin}/*`);
@@ -241,6 +307,7 @@ async function run() {
"remote.active-protocols": 1, "remote.active-protocols": 1,
}; };
if (packaged) prefs["xpinstall.signatures.required"] = false; if (packaged) prefs["xpinstall.signatures.required"] = false;
if (consentDisabled) prefs["extensions.dataCollectionPermissions.enabled"] = false;
await fs.writeFile( await fs.writeFile(
path.join(profile, "user.js"), path.join(profile, "user.js"),
Object.entries(prefs) Object.entries(prefs)
@@ -323,6 +390,40 @@ async function run() {
[], [],
true, true,
); );
async function assertPermissionState(label, expected) {
const state = await driver.script(
`const done = arguments[arguments.length - 1]; Promise.all([
browser.permissions.getAll(),
browser.permissions.contains({data_collection:["authenticationInfo"]})
]).then(([all, contains]) => done({all, contains, events:window.__rydPermissionEvents || []}));`,
[],
true,
);
result.permissionStates.push({ label, ...state });
assert.equal(state.all.data_collection?.includes("authenticationInfo") === true, expected, label);
return state;
}
async function assertNoAuthenticationTraffic(label) {
const baseline = requests.filter(
(request) => request.path.startsWith("/api/auth/") || request.path === "/oauth-authorize",
).length;
for (const message of ["patreon_oauth_login", "github_oauth_login"]) {
const response = await driver.script(
`const done = arguments[arguments.length - 1]; browser.runtime.sendMessage({message:arguments[0]}).then(done);`,
[message],
true,
);
assert.equal(response.success, false, label);
assert.match(response.error, /consent removed|consent required/, label);
}
await delay(1000);
assert.equal(
requests.filter((request) => request.path.startsWith("/api/auth/") || request.path === "/oauth-authorize")
.length,
baseline,
label,
);
}
if (!packaged) { if (!packaged) {
result.freshInstallChangelog.storage = await until( result.freshInstallChangelog.storage = await until(
() => () =>
@@ -337,7 +438,9 @@ async function run() {
} }
const optional = manifest.browser_specific_settings.gecko.data_collection_permissions.optional; const optional = manifest.browser_specific_settings.gecko.data_collection_permissions.optional;
assert.deepEqual(optional, ["authenticationInfo"]); assert.deepEqual(optional, ["authenticationInfo"]);
assert(optional.every((permission) => !result.initialPermissions.data_collection.includes(permission))); assert(optional.every((permission) => !result.initialPermissions.data_collection?.includes(permission)));
await assertPermissionState("fresh installation has no authentication consent", false);
if (consentDisabled) assert.equal(result.initialPermissions.data_collection, undefined);
for (const message of ["patreon_oauth_login", "github_oauth_login"]) { for (const message of ["patreon_oauth_login", "github_oauth_login"]) {
const deniedMessage = await driver.script( const deniedMessage = await driver.script(
`const done=arguments[arguments.length-1]; browser.runtime.sendMessage({message:arguments[0]}).then(done);`, `const done=arguments[arguments.length-1]; browser.runtime.sendMessage({message:arguments[0]}).then(done);`,
@@ -361,6 +464,11 @@ async function run() {
); );
assert.equal(requests.filter((request) => request.path.startsWith("/api/auth/")).length, 0); assert.equal(requests.filter((request) => request.path.startsWith("/api/auth/")).length, 0);
result.scenarios.push("cached account cannot verify or log in without consent"); result.scenarios.push("cached account cannot verify or log in without consent");
await driver.script(
`window.__rydPermissionEvents = [];
browser.permissions.onAdded.addListener(value => window.__rydPermissionEvents.push({type:"added", ...value}));
browser.permissions.onRemoved.addListener(value => window.__rydPermissionEvents.push({type:"removed", ...value}));`,
);
await delay(1000); await delay(1000);
await driver.script( await driver.script(
`const done=arguments[arguments.length-1]; browser.tabs.getCurrent().then(tab=>browser.tabs.update(tab.id,{active:true})).then(()=>done(true));`, `const done=arguments[arguments.length-1]; browser.tabs.getCurrent().then(tab=>browser.tabs.update(tab.id,{active:true})).then(()=>done(true));`,
@@ -392,140 +500,185 @@ async function run() {
const shot = await driver.command("WebDriver:TakeScreenshot", { full: true }); const shot = await driver.command("WebDriver:TakeScreenshot", { full: true });
await fs.writeFile(path.join(evidence, "popup.png"), Buffer.from(shot.value, "base64")); await fs.writeFile(path.join(evidence, "popup.png"), Buffer.from(shot.value, "base64"));
result.screenshots.push("popup.png"); result.screenshots.push("popup.png");
await driver.click("#patreon-login-btn"); if (consentDisabled) {
await driver.context("chrome"); for (const selector of ["#patreon-login-btn", "#github-login-btn"]) {
result.nativePermissionPrompt = await until( await driver.click(selector);
() => await until(async () => {
driver.script( try {
`const notification = document.getElementById("addon-webext-permissions-notification"); return document.getElementById("notification-popup")?.state === "open" && notification ? { heading:notification.getAttribute("endlabel"), data:document.getElementById("addon-webext-perm-list-data-collection").textContent, allow:notification.getAttribute("buttonlabel"), deny:notification.getAttribute("secondarybuttonlabel") } : false;`, await driver.command("WebDriver:GetAlertText");
), return true;
"native consent prompt", } catch (_) {
); return false;
console.log("NATIVE PROMPT", JSON.stringify(result.nativePermissionPrompt)); }
assert.match(result.nativePermissionPrompt.data, /authentication information/i); }, "missing-consent notice with native data consent disabled");
assert.doesNotMatch(result.nativePermissionPrompt.data, /financial|payment/i); await driver.command("WebDriver:DismissAlert");
// Native arrow panels live outside the headless compositor screenshot. Preserve their exact text instead. await assertPermissionState(selector + " stays denied without native data consent", false);
await driver.script(
`document.querySelector("#addon-webext-permissions-notification .popup-notification-secondary-button").click();`,
);
await driver.context("content");
await until(async () => {
try {
await driver.command("WebDriver:GetAlertText");
return true;
} catch (_) {
return false;
} }
}, "extension denial notice"); await assertNoAuthenticationTraffic("disabled native consent cannot authorize either provider");
await driver.command("WebDriver:DismissAlert"); assert.equal(requests.filter((request) => request.path.startsWith("/api/auth/")).length, 0);
const denied = await driver.script( result.scenarios.push(
`const done=arguments[arguments.length-1]; browser.permissions.contains({data_collection:arguments[0]}).then(done);`, "disabled native data-consent API rejects cached accounts and both login gestures without authentication traffic",
[optional], );
true, result.validationDepth =
); "generated Firefox artifact with loopback API substitution and native data-consent API disabled";
assert.equal(denied, false); } else {
assert.equal(requests.filter((request) => request.path.startsWith("/api/auth/")).length, 0); await driver.click("#patreon-login-btn");
result.scenarios.push("native Deny prevents OAuth requests"); await driver.context("chrome");
console.log("DENIAL PASSED"); result.nativePermissionPrompt = await until(
await driver.click("#patreon-login-btn"); () =>
await driver.context("chrome"); driver.script(
await until( `const notification = document.getElementById("addon-webext-permissions-notification"); return document.getElementById("notification-popup")?.state === "open" && notification ? { heading:notification.getAttribute("endlabel"), data:document.getElementById("addon-webext-perm-list-data-collection").textContent, allow:notification.getAttribute("buttonlabel"), deny:notification.getAttribute("secondarybuttonlabel") } : false;`,
() => driver.script(`return document.getElementById("notification-popup")?.state === "open";`), ),
"second consent prompt", "native consent prompt",
); );
await driver.script( console.log("NATIVE PROMPT", JSON.stringify(result.nativePermissionPrompt));
`document.querySelector("#addon-webext-permissions-notification .popup-notification-primary-button").click();`, assert.match(result.nativePermissionPrompt.data, /authentication information/i);
); assert.doesNotMatch(result.nativePermissionPrompt.data, /financial|payment/i);
await driver.context("content"); // Native arrow panels live outside the headless compositor screenshot. Preserve their exact text instead.
await until( await driver.script(
() => driver.script(`return document.getElementById("patreon-logged-in").style.display === "block";`), `document.querySelector("#addon-webext-permissions-notification .popup-notification-secondary-button").click();`,
"mock OAuth completion", );
); await driver.context("content");
const granted = await driver.script( await until(async () => {
`const done=arguments[arguments.length-1]; browser.permissions.contains({data_collection:arguments[0]}).then(done);`, try {
[optional], await driver.command("WebDriver:GetAlertText");
true, return true;
); } catch (_) {
assert.equal(granted, true); return false;
assert.equal(requests.filter((request) => request.path === "/api/auth/oauth/login").length, 1); }
assert.equal( }, "extension denial notice");
requests.filter((request) => request.path === "/api/auth/oauth/exchange" && request.method === "POST").length, await driver.command("WebDriver:DismissAlert");
1, const denied = await driver.script(
); `const done=arguments[arguments.length-1]; browser.permissions.contains({data_collection:arguments[0]}).then(done);`,
result.scenarios.push("native Allow completes generated Patreon OAuth against loopback"); [optional],
assert.equal( true,
await driver.script(`return document.getElementById("patreon-privacy-note").checkVisibility();`), );
false, assert.equal(denied, false);
); await assertPermissionState("native Deny leaves authentication consent absent", false);
console.log("GRANT PASSED"); assert.equal(requests.filter((request) => request.path.startsWith("/api/auth/")).length, 0);
const grantShot = await driver.command("WebDriver:TakeScreenshot", { full: true }); result.scenarios.push("native Deny prevents OAuth requests");
await fs.writeFile(path.join(evidence, "popup-granted.png"), Buffer.from(grantShot.value, "base64")); console.log("DENIAL PASSED");
result.screenshots.push("popup-granted.png"); await driver.click("#patreon-login-btn");
await driver.script( await driver.context("chrome");
`const done=arguments[arguments.length-1]; browser.permissions.remove({data_collection:arguments[0]}).then(done);`, await until(
[optional], () => driver.script(`return document.getElementById("notification-popup")?.state === "open";`),
true, "second consent prompt",
); );
await until( await driver.script(
() => `document.querySelector("#addon-webext-permissions-notification .popup-notification-primary-button").click();`,
driver.script( );
`const done=arguments[arguments.length-1]; browser.storage.sync.get(["patreonAuthenticated","patreonUser","patreonSessionToken"]).then(value=>done(Object.keys(value).length===0));`, await driver.context("content");
[], await until(
true, () => driver.script(`return document.getElementById("patreon-logged-in").style.display === "block";`),
"mock OAuth completion",
);
const granted = await driver.script(
`const done=arguments[arguments.length-1]; browser.permissions.contains({data_collection:arguments[0]}).then(done);`,
[optional],
true,
);
assert.equal(granted, true);
const grantedState = await assertPermissionState("native Allow grants authentication consent", true);
assert(
grantedState.events.some(
(event) => event.type === "added" && event.data_collection?.includes("authenticationInfo"),
), ),
"stored authentication cleanup after revocation", );
); assert.equal(requests.filter((request) => request.path === "/api/auth/oauth/login").length, 1);
await until( assert.equal(
() => driver.script(`return document.getElementById("patreon-logged-out").style.display === "block";`), requests.filter((request) => request.path === "/api/auth/oauth/exchange" && request.method === "POST").length,
"logged-out UI after revocation", 1,
); );
result.scenarios.push("native revocation removes stored authentication and logs out popup"); result.scenarios.push("native Allow completes generated Patreon OAuth against loopback");
console.log("REVOCATION PASSED"); assert.equal(
await driver.click("#github-login-btn"); await driver.script(`return document.getElementById("patreon-privacy-note").checkVisibility();`),
await driver.context("chrome"); false,
await until( );
() => driver.script(`return document.getElementById("notification-popup")?.state === "open";`), console.log("GRANT PASSED");
"GitHub consent prompt", const grantShot = await driver.command("WebDriver:TakeScreenshot", { full: true });
); await fs.writeFile(path.join(evidence, "popup-granted.png"), Buffer.from(grantShot.value, "base64"));
await driver.script( result.screenshots.push("popup-granted.png");
`document.querySelector("#addon-webext-permissions-notification .popup-notification-primary-button").click();`, await driver.script(
); `const done=arguments[arguments.length-1]; browser.permissions.remove({data_collection:arguments[0]}).then(done);`,
await driver.context("content"); [optional],
await until( true,
() => driver.script(`return document.getElementById("patreon-logged-in").style.display === "block";`), );
"GitHub mock OAuth completion", await until(
); () =>
assert.equal( driver.script(
requests.filter((request) => request.path === "/api/auth/github/login" && request.method === "GET").length, `const done=arguments[arguments.length-1]; browser.storage.sync.get(["patreonAuthenticated","patreonUser","patreonSessionToken"]).then(value=>done(Object.keys(value).length===0));`,
1, [],
); true,
assert.equal( ),
requests.filter((request) => request.path === "/api/auth/github/exchange" && request.method === "POST").length, "stored authentication cleanup after revocation",
1, );
); await until(
result.scenarios.push("native Allow completes generated GitHub OAuth against loopback"); () => driver.script(`return document.getElementById("patreon-logged-out").style.display === "block";`),
await driver.script( "logged-out UI after revocation",
`const done=arguments[arguments.length-1]; browser.permissions.remove({data_collection:["authenticationInfo"]}).then(done);`, );
[], const revokedState = await assertPermissionState("native revocation removes authentication consent", false);
true, assert(
); revokedState.events.some(
await until( (event) => event.type === "removed" && event.data_collection?.includes("authenticationInfo"),
() =>
driver.script(
`const done=arguments[arguments.length-1]; browser.storage.sync.get(["patreonAuthenticated","patreonUser","patreonSessionToken"]).then(value=>done(Object.keys(value).length===0));`,
[],
true,
), ),
"authentication permission revocation clears GitHub authentication", );
); await assertNoAuthenticationTraffic("Patreon revocation blocks subsequent account requests");
await until( result.scenarios.push(
() => driver.script(`return document.getElementById("patreon-logged-out").style.display === "block";`), "native revocation removes stored authentication, logs out popup, and blocks subsequent account traffic",
"GitHub logged-out UI after authentication permission revocation", );
); console.log("REVOCATION PASSED");
result.scenarios.push("revoking authentication category clears GitHub account state"); await driver.click("#github-login-btn");
const revokeShot = await driver.command("WebDriver:TakeScreenshot", { full: true }); await driver.context("chrome");
await fs.writeFile(path.join(evidence, "popup-revoked.png"), Buffer.from(revokeShot.value, "base64")); await until(
result.screenshots.push("popup-revoked.png"); () => driver.script(`return document.getElementById("notification-popup")?.state === "open";`),
"GitHub consent prompt",
);
await driver.script(
`document.querySelector("#addon-webext-permissions-notification .popup-notification-primary-button").click();`,
);
await driver.context("content");
await until(
() => driver.script(`return document.getElementById("patreon-logged-in").style.display === "block";`),
"GitHub mock OAuth completion",
);
assert.equal(
requests.filter((request) => request.path === "/api/auth/github/login" && request.method === "GET").length,
1,
);
assert.equal(
requests.filter((request) => request.path === "/api/auth/github/exchange" && request.method === "POST").length,
1,
);
assert.equal(result.githubPkceVerified, true);
await assertPermissionState("GitHub native Allow grants authentication consent", true);
result.scenarios.push("native Allow completes generated GitHub OAuth against loopback");
await driver.script(
`const done=arguments[arguments.length-1]; browser.permissions.remove({data_collection:["authenticationInfo"]}).then(done);`,
[],
true,
);
await until(
() =>
driver.script(
`const done=arguments[arguments.length-1]; browser.storage.sync.get(["patreonAuthenticated","patreonUser","patreonSessionToken"]).then(value=>done(Object.keys(value).length===0));`,
[],
true,
),
"authentication permission revocation clears GitHub authentication",
);
await until(
() => driver.script(`return document.getElementById("patreon-logged-out").style.display === "block";`),
"GitHub logged-out UI after authentication permission revocation",
);
await assertPermissionState("GitHub revocation removes authentication consent", false);
await assertNoAuthenticationTraffic("GitHub revocation blocks subsequent account requests");
result.scenarios.push(
"revoking authentication category clears GitHub account state and blocks subsequent account traffic",
);
const revokeShot = await driver.command("WebDriver:TakeScreenshot", { full: true });
await fs.writeFile(path.join(evidence, "popup-revoked.png"), Buffer.from(revokeShot.value, "base64"));
result.screenshots.push("popup-revoked.png");
}
if (changelogLifecycle) if (changelogLifecycle)
await require("./firefox-changelog-lifecycle").observeChangelogLifecycle({ await require("./firefox-changelog-lifecycle").observeChangelogLifecycle({
driver, driver,
@@ -551,6 +704,10 @@ async function run() {
!blocked.some((request) => request.host.includes("returnyoutubedislikeapi.com")), !blocked.some((request) => request.host.includes("returnyoutubedislikeapi.com")),
"Extension traffic escaped loopback substitution", "Extension traffic escaped loopback substitution",
); );
assert(
!blocked.some((request) => /(^|\.)(github\.com|patreon\.com)(:|$)/i.test(request.host)),
"Authentication traffic escaped loopback substitution",
);
assert.deepEqual(unexpectedRequests, [], "Unexpected extension request"); assert.deepEqual(unexpectedRequests, [], "Unexpected extension request");
result.passed = true; result.passed = true;
} catch (error) { } catch (error) {
+1 -1
View File
@@ -182,7 +182,7 @@ async function validatePackagedConsent({ driver, derived, evidence, manifest, re
"accepted packaged update", "accepted packaged update",
); );
await dismissFinishedNotice(driver); await dismissFinishedNotice(driver);
result.scenarios.push("native required-consent update acceptance activates 4.0.5"); result.scenarios.push(`native required-consent update acceptance activates ${manifest.version}`);
console.log("REQUIRED UPDATE ACCEPTANCE PASSED"); console.log("REQUIRED UPDATE ACCEPTANCE PASSED");
result.validationDepth = result.validationDepth =
"packaged Firefox Developer artifact with loopback API substitution, native required install/update and optional consent"; "packaged Firefox Developer artifact with loopback API substitution, native required install/update and optional consent";
+4 -5
View File
@@ -465,12 +465,11 @@ function prepareHermeticExtensionArtifact(sourceDirectory, apiOrigin) {
false, false,
"ryd.background.js still contains the production API origin after transformation.", "ryd.background.js still contains the production API origin after transformation.",
); );
const changelogListener = `api.runtime.onInstalled.addListener((details) => { const changelogListener =
maybeShowChangelog(details); /api\.runtime\.onInstalled\.addListener\(\(details\) => \{\r?\n maybeShowChangelog\(details\);\r?\n\}\);/g;
});`; if ((transformedBackground.match(changelogListener) ?? []).length !== 1) {
if (!transformedBackground.includes(changelogListener)) {
removeOwnedTemporaryDirectory(temporaryRoot, "ryd-mv3-e2e-"); removeOwnedTemporaryDirectory(temporaryRoot, "ryd-mv3-e2e-");
throw new Error("ryd.background.js has no recognized first-install changelog listener to suppress."); throw new Error("ryd.background.js must have exactly one recognized first-install changelog listener to suppress.");
} }
const hermeticBackground = transformedBackground.replace( const hermeticBackground = transformedBackground.replace(
changelogListener, changelogListener,
+52 -27
View File
@@ -37,7 +37,7 @@ const {
const PRODUCTION_API_ORIGIN = "https://returnyoutubedislikeapi.com"; const PRODUCTION_API_ORIGIN = "https://returnyoutubedislikeapi.com";
const temporaryDirectories = []; const temporaryDirectories = [];
function createExtensionFixture() { function createExtensionFixture(lineEnding = "\n") {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-mv3-source-fixture-")); const directory = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-mv3-source-fixture-"));
temporaryDirectories.push(directory); temporaryDirectories.push(directory);
fs.writeFileSync( fs.writeFileSync(
@@ -54,7 +54,10 @@ function createExtensionFixture() {
); );
fs.writeFileSync( fs.writeFileSync(
path.join(directory, "ryd.background.js"), path.join(directory, "ryd.background.js"),
`fetch("${PRODUCTION_API_ORIGIN}/register")\napi.runtime.onInstalled.addListener((details) => {\n maybeShowChangelog(details);\n});`, `fetch("${PRODUCTION_API_ORIGIN}/register")\napi.runtime.onInstalled.addListener((details) => {\n maybeShowChangelog(details);\n});`.replaceAll(
"\n",
lineEnding,
),
); );
fs.writeFileSync(path.join(directory, "ryd.content-script.js"), `fetch("${PRODUCTION_API_ORIGIN}/votes")`); fs.writeFileSync(path.join(directory, "ryd.content-script.js"), `fetch("${PRODUCTION_API_ORIGIN}/votes")`);
fs.writeFileSync(path.join(directory, "content-style.css"), "#ryd-bar { display: block; }"); fs.writeFileSync(path.join(directory, "content-style.css"), "#ryd-bar { display: block; }");
@@ -351,32 +354,54 @@ test("the routed fake backend records a valid preflight and blocks an unknown on
]); ]);
}); });
test("redirects eager MV3 background traffic while leaving routed content-script traffic intact", () => { test.each(["\n", "\r\n"])(
const sourceDirectory = createExtensionFixture(); "redirects MV3 background traffic with %j line endings and preserves routed traffic",
const prepared = prepareHermeticExtensionArtifact(sourceDirectory, "http://127.0.0.1:43127"); (lineEnding) => {
temporaryDirectories.push(prepared.temporaryRoot); const sourceDirectory = createExtensionFixture(lineEnding);
const prepared = prepareHermeticExtensionArtifact(sourceDirectory, "http://127.0.0.1:43127");
temporaryDirectories.push(prepared.temporaryRoot);
expect(prepared.replacements).toEqual({ "ryd.background.js": 1, firstInstallChangelogListener: 1 }); expect(prepared.replacements).toEqual({ "ryd.background.js": 1, firstInstallChangelogListener: 1 });
expect(prepared.routedBundles).toEqual(["ryd.content-script.js"]); expect(prepared.routedBundles).toEqual(["ryd.content-script.js"]);
expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.background.js"), "utf8")).toContain( expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.background.js"), "utf8")).toContain(
"http://127.0.0.1:43127/register", "http://127.0.0.1:43127/register",
); );
expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.background.js"), "utf8")).toContain( expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.background.js"), "utf8")).toContain(
"__rydArtifactWorkerSignals", "__rydArtifactWorkerSignals",
); );
expect(prepared.workerSignalEndpoint).toBe(`http://127.0.0.1:43127${WORKER_SIGNAL_PATH}`); expect(prepared.workerSignalEndpoint).toBe(`http://127.0.0.1:43127${WORKER_SIGNAL_PATH}`);
expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.background.js"), "utf8")).toContain( expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.background.js"), "utf8")).toContain(
"api.runtime.onInstalled.addListener(() => {});", "api.runtime.onInstalled.addListener(() => {});",
); );
expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.content-script.js"), "utf8")).toContain( expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.content-script.js"), "utf8")).toContain(
`${PRODUCTION_API_ORIGIN}/votes`, `${PRODUCTION_API_ORIGIN}/votes`,
); );
expect(fs.readFileSync(path.join(prepared.extensionDirectory, "menu-fixer.js"), "utf8")).toContain("menuFixerLoaded"); expect(fs.readFileSync(path.join(prepared.extensionDirectory, "menu-fixer.js"), "utf8")).toContain(
expect(JSON.parse(fs.readFileSync(path.join(prepared.extensionDirectory, "manifest.json"), "utf8"))).toMatchObject({ "menuFixerLoaded",
host_permissions: expect.arrayContaining(["http://127.0.0.1/*"]), );
manifest_version: 3, expect(JSON.parse(fs.readFileSync(path.join(prepared.extensionDirectory, "manifest.json"), "utf8"))).toMatchObject({
}); host_permissions: expect.arrayContaining(["http://127.0.0.1/*"]),
expect(fs.readFileSync(path.join(sourceDirectory, "ryd.background.js"), "utf8")).toContain(PRODUCTION_API_ORIGIN); manifest_version: 3,
});
expect(fs.readFileSync(path.join(sourceDirectory, "ryd.background.js"), "utf8")).toContain(PRODUCTION_API_ORIGIN);
},
);
test.each([
[
"an unexpected listener body",
(source) => source.replace("maybeShowChangelog(details);", "showOtherPage(details);"),
],
["duplicate changelog listeners", (source) => `${source}\n${source}`],
])("rejects %s instead of suppressing an unrecognized listener", (_label, mutateBackground) => {
const sourceDirectory = createExtensionFixture();
const backgroundPath = path.join(sourceDirectory, "ryd.background.js");
fs.writeFileSync(backgroundPath, mutateBackground(fs.readFileSync(backgroundPath, "utf8")));
expect(() => {
const prepared = prepareHermeticExtensionArtifact(sourceDirectory, "http://127.0.0.1:43127");
temporaryDirectories.push(prepared.temporaryRoot);
}).toThrow(/exactly one recognized first-install changelog listener/);
}); });
test("rejects an extension artifact whose injected auxiliary script was dropped by the build", () => { test("rejects an extension artifact whose injected auxiliary script was dropped by the build", () => {
+2 -2
View File
@@ -4,7 +4,7 @@ This source package is provided for Mozilla Add-ons review. The extension is bui
## Build environment ## Build environment
The 4.0.5 release was built with this exact toolchain: Build the 4.0.6 release with this exact toolchain:
- Windows 11 (PowerShell); the build script also supports Linux and macOS - Windows 11 (PowerShell); the build script also supports Linux and macOS
- Node.js 22.17.0 - Node.js 22.17.0
@@ -49,7 +49,7 @@ The script performs all required technical build steps:
1. Confirms Node.js 22.17.0 and npm 10.8.2 are in use. 1. Confirms Node.js 22.17.0 and npm 10.8.2 are in use.
2. Installs the locked dependencies with `npm ci`. 2. Installs the locked dependencies with `npm ci`.
3. Builds the extension with the release-specific `npm run build-for-amo` hook. 3. Builds the extension with the release-specific `npm run build-for-amo` hook.
4. Verifies the generated Firefox manifest is version 4.0.5, retains the published AMO add-on ID, and declares the expected desktop data-collection permissions and minimum Firefox version. 4. Verifies the generated Firefox manifest is version 4.0.6, retains the published AMO add-on ID, and declares the expected desktop data-collection permissions and minimum Firefox version.
5. Verifies `menu-fixer.js` and the other required Firefox files were generated. 5. Verifies `menu-fixer.js` and the other required Firefox files were generated.
6. Verifies `ryd.content-script.js` is below 5 MB. 6. Verifies `ryd.content-script.js` is below 5 MB.
7. Verifies every generated JavaScript file has no source-map reference. 7. Verifies every generated JavaScript file has no source-map reference.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "return-youtube-dislike", "name": "return-youtube-dislike",
"version": "4.0.5", "version": "4.0.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "return-youtube-dislike", "name": "return-youtube-dislike",
"version": "4.0.5", "version": "4.0.6",
"engines": { "engines": {
"node": "22.17.0", "node": "22.17.0",
"npm": "10.8.2" "npm": "10.8.2"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "return-youtube-dislike", "name": "return-youtube-dislike",
"version": "4.0.5", "version": "4.0.6",
"description": "Chrome extension to return youtube dislikes", "description": "Chrome extension to return youtube dislikes",
"main": "ryd.content-script.js", "main": "ryd.content-script.js",
"packageManager": "npm@10.8.2", "packageManager": "npm@10.8.2",
+1 -1
View File
@@ -12,7 +12,7 @@ const menuFixerPath = join(firefoxOutputDirectory, "menu-fixer.js");
const maxContentScriptBytes = 5 * 1024 * 1024; const maxContentScriptBytes = 5 * 1024 * 1024;
const expectedNodeVersion = "v22.17.0"; const expectedNodeVersion = "v22.17.0";
const expectedNpmVersion = "10.8.2"; const expectedNpmVersion = "10.8.2";
const expectedExtensionVersion = "4.0.5"; const expectedExtensionVersion = "4.0.6";
const expectedFirefoxExtensionId = "{762f9885-5a13-4abd-9c77-433dcd38b8fd}"; const expectedFirefoxExtensionId = "{762f9885-5a13-4abd-9c77-433dcd38b8fd}";
const expectedRequiredDataCollectionPermissions = [ const expectedRequiredDataCollectionPermissions = [
"personallyIdentifyingInfo", "personallyIdentifyingInfo",
+2
View File
@@ -13,6 +13,7 @@ const EXPECTED_NODE = "v22.17.0";
const EXPECTED_NPM = "10.8.2"; const EXPECTED_NPM = "10.8.2";
const ARTIFACT_VERIFIER_PATH = "Extensions/e2e/verify-extension-artifact.js"; const ARTIFACT_VERIFIER_PATH = "Extensions/e2e/verify-extension-artifact.js";
const EXTRA_SOURCE_FILES = Object.freeze([ const EXTRA_SOURCE_FILES = Object.freeze([
"AMO_RELEASE_NOTES.md",
"AMO_SUBMISSION_NOTES.md", "AMO_SUBMISSION_NOTES.md",
"Docs/Privacy Policy", "Docs/Privacy Policy",
ARTIFACT_VERIFIER_PATH, ARTIFACT_VERIFIER_PATH,
@@ -300,6 +301,7 @@ async function packageRelease(outputDirectory) {
); );
verifyBuildReceipt(); verifyBuildReceipt();
for (const [source, destination] of [ for (const [source, destination] of [
["AMO_RELEASE_NOTES.md", "AMO_RELEASE_NOTES.md"],
["AMO_SUBMISSION_NOTES.md", "AMO_SUBMISSION_NOTES.md"], ["AMO_SUBMISSION_NOTES.md", "AMO_SUBMISSION_NOTES.md"],
["README_AMO_SOURCE.md", "README_AMO_SOURCE.md"], ["README_AMO_SOURCE.md", "README_AMO_SOURCE.md"],
["Docs/Privacy Policy", "PRIVACY_POLICY.md"], ["Docs/Privacy Policy", "PRIVACY_POLICY.md"],
+1
View File
@@ -95,6 +95,7 @@ test("source allowlist includes the standalone build and packaging tools but exc
expect(files).toEqual( expect(files).toEqual(
expect.arrayContaining([ expect.arrayContaining([
"package-lock.json", "package-lock.json",
"AMO_RELEASE_NOTES.md",
"README_AMO_SOURCE.md", "README_AMO_SOURCE.md",
"scripts/build-firefox-source.mjs", "scripts/build-firefox-source.mjs",
"scripts/package-amo-release.cjs", "scripts/package-amo-release.cjs",