Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/env/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ function createServerEnv() {
.string()
.optional()
.describe("Chrome Web Store extension id allowed to receive auth keys"),
CAP_FIREFOX_EXTENSION_ID: z
.string()
.optional()
.describe(
"Firefox identity redirect subdomain (the <id> in https://<id>.extensions.allizom.org, derived from the gecko extension id) allowed to receive auth keys",
),
CAP_ALLOWED_SIGNUP_DOMAINS: z
.string()
.optional()
Expand Down
33 changes: 21 additions & 12 deletions packages/web-backend/src/Extension/Http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,18 @@ import { handleDomainError } from "../Http/Errors.ts";
import { Videos } from "../Videos/index.ts";
import { Extensions } from "./Extensions.ts";

const CHROMIUM_IDENTITY_HOST_SUFFIX = ".chromiumapp.org";
// Each browser's identity.launchWebAuthFlow intercepts redirects to its own
// synthetic host; the leading label identifies the extension installation.
const IDENTITY_REDIRECT_HOSTS = [
{
suffix: ".chromiumapp.org",
getConfiguredExtensionId: () => serverEnv().CAP_CHROME_EXTENSION_ID,
},
{
suffix: ".extensions.allizom.org",
getConfiguredExtensionId: () => serverEnv().CAP_FIREFOX_EXTENSION_ID,
},
] as const;

const validateExtensionRedirectUri = (redirectUri: string) =>
Effect.gen(function* () {
Expand All @@ -29,18 +40,15 @@ const validateExtensionRedirectUri = (redirectUri: string) =>
catch: () => new HttpApiError.BadRequest(),
});

if (
url.protocol !== "https:" ||
!url.hostname.endsWith(CHROMIUM_IDENTITY_HOST_SUFFIX)
) {
const identityHost = IDENTITY_REDIRECT_HOSTS.find(({ suffix }) =>
url.hostname.endsWith(suffix),
);
if (url.protocol !== "https:" || !identityHost) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth rejecting non-default ports / credentials here to keep the redirect URI shape tight (and avoid surprises if browser behavior changes).

Suggested change
if (url.protocol !== "https:" || !identityHost) {
if (
url.protocol !== "https:" ||
url.port !== "" ||
url.username ||
url.password ||
!identityHost
) {
return yield* new HttpApiError.BadRequest();
}

return yield* new HttpApiError.BadRequest();
}

const extensionId = url.hostname.slice(
0,
-CHROMIUM_IDENTITY_HOST_SUFFIX.length,
);
const configuredExtensionId = serverEnv().CAP_CHROME_EXTENSION_ID;
const extensionId = url.hostname.slice(0, -identityHost.suffix.length);
const configuredExtensionId = identityHost.getConfiguredExtensionId();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Mixed-Case Firefox Id Rejection

URL.hostname is normalized to lowercase before extensionId is sliced, but the new Firefox id comes straight from CAP_FIREFOX_EXTENSION_ID. If that env var is copied with uppercase characters, the configured-id check rejects the real Firefox redirect host and the extension auth flow always returns a bad request.

Suggested change
const configuredExtensionId = identityHost.getConfiguredExtensionId();
const configuredExtensionId = identityHost
.getConfiguredExtensionId()
?.toLowerCase();
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/web-backend/src/Extension/Http.ts
Line: 51

Comment:
**Mixed-Case Firefox Id Rejection**

`URL.hostname` is normalized to lowercase before `extensionId` is sliced, but the new Firefox id comes straight from `CAP_FIREFOX_EXTENSION_ID`. If that env var is copied with uppercase characters, the configured-id check rejects the real Firefox redirect host and the extension auth flow always returns a bad request.

```suggestion
		const configuredExtensionId = identityHost
			.getConfiguredExtensionId()
			?.toLowerCase();
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +50 to +51

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the comment says the leading label identifies the extension installation, it could be good to explicitly reject multi-label hosts (e.g. a.b.chromiumapp.org).

Suggested change
const extensionId = url.hostname.slice(0, -identityHost.suffix.length);
const configuredExtensionId = identityHost.getConfiguredExtensionId();
const extensionId = url.hostname.slice(0, -identityHost.suffix.length);
if (extensionId.includes(".")) {
return yield* new HttpApiError.BadRequest();
}
const configuredExtensionId = identityHost.getConfiguredExtensionId();


if (configuredExtensionId) {
if (extensionId !== configuredExtensionId) {
Expand All @@ -53,7 +61,8 @@ const validateExtensionRedirectUri = (redirectUri: string) =>
// signed-in user's auth key through this flow. The only deployment
// where accepting an arbitrary id is safe is localhost development;
// every reachable deployment (staging, previews, self-hosted) must set
// CAP_CHROME_EXTENSION_ID regardless of NODE_ENV.
// CAP_CHROME_EXTENSION_ID / CAP_FIREFOX_EXTENSION_ID regardless of
// NODE_ENV.
const webHostname = new URL(serverEnv().WEB_URL).hostname;
const isLocalDevelopment =
serverEnv().NODE_ENV !== "production" &&
Expand Down Expand Up @@ -151,7 +160,7 @@ const renderConsentPage = ({
</head>
<body>
<main class="card">
<h1>Connect the Cap Chrome extension</h1>
<h1>Connect the Cap browser extension</h1>
<p>The Cap extension is asking for access to your Cap account <span class="email">${escapeHtml(email)}</span> to create and upload recordings on your behalf.</p>
<p>Only continue if you opened this page from the Cap extension.</p>
<form method="post" action="approve" class="actions">
Expand Down
Loading