Skip to content

Commit

Permalink
Validate redirect url (#20152)
Browse files Browse the repository at this point in the history
* Validate redirect url

* Address feedback

* feedback 2
  • Loading branch information
mustard-mh authored Aug 27, 2024
1 parent 97ba0a7 commit b6c2db7
Show file tree
Hide file tree
Showing 5 changed files with 43 additions and 7 deletions.
4 changes: 2 additions & 2 deletions components/dashboard/src/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { UserContext } from "./user-context";
import { getGitpodService } from "./service/service";
import { iconForAuthProvider, openAuthorizeWindow, simplifyProviderName } from "./provider-utils";
import exclamation from "./images/exclamation.svg";
import { getURLHash } from "./utils";
import { getURLHash, isTrustedUrlOrPath } from "./utils";
import ErrorMessage from "./components/ErrorMessage";
import { Heading1, Heading2, Subheading } from "./components/typography/headings";
import { SSOLoginForm } from "./login/SSOLoginForm";
Expand Down Expand Up @@ -84,7 +84,7 @@ export const Login: FC<LoginProps> = ({ onLoggedIn }) => {
const returnToPath = new URLSearchParams(window.location.search).get("returnToPath");
if (returnToPath) {
const isAbsoluteURL = /^https?:\/\//i.test(returnToPath);
if (!isAbsoluteURL) {
if (!isAbsoluteURL && isTrustedUrlOrPath(returnToPath)) {
window.location.replace(returnToPath);
}
}
Expand Down
7 changes: 6 additions & 1 deletion components/dashboard/src/OauthClientApproval.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { Button } from "@podkit/buttons/Button";
import gitpodIcon from "./icons/gitpod.svg";
import { Heading1, Subheading } from "@podkit/typography/Headings";
import { isTrustedUrlOrPath } from "./utils";

export default function OAuthClientApproval() {
const params = new URLSearchParams(window.location.search);
Expand All @@ -23,8 +24,12 @@ export default function OAuthClientApproval() {
const updateClientApproval = async (isApproved: boolean) => {
if (redirectTo === "/") {
window.location.replace(redirectTo);
return;
}
const url = `${redirectTo}&approved=${isApproved ? "yes" : "no"}`;
if (isTrustedUrlOrPath(url)) {
window.location.replace(url);
}
window.location.replace(`${redirectTo}&approved=${isApproved ? "yes" : "no"}`);
};

return (
Expand Down
18 changes: 16 additions & 2 deletions components/dashboard/src/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* See License.AGPL.txt in the project root for license information.
*/

import { inResource, getURLHash } from "./utils";
import { inResource, getURLHash, isTrustedUrlOrPath } from "./utils";

test("inResource", () => {
// Given root path is a part of resources specified
Expand All @@ -26,13 +26,27 @@ test("inResource", () => {
expect(inResource("/admin/teams/someTeam/somePerson", ["/admin/teams"])).toBe(true);
});

test("urlHash", () => {
test("urlHash and isTrustedUrlOrPath", () => {
global.window = Object.create(window);
Object.defineProperty(window, "location", {
value: {
hash: "#https://example.org/user/repo",
hostname: "example.org",
},
});

expect(getURLHash()).toBe("https://example.org/user/repo");

const isTrustedUrlOrPathCases: { location: string; trusted: boolean }[] = [
{ location: "https://example.org/user/repo", trusted: true },
{ location: "https://example.org/user", trusted: true },
{ location: "https://example2.org/user", trusted: false },
{ location: "/api/hello", trusted: true },
{ location: "/", trusted: true },
// eslint-disable-next-line no-script-url
{ location: "javascript:alert(1)", trusted: false },
];
isTrustedUrlOrPathCases.forEach(({ location, trusted }) => {
expect(isTrustedUrlOrPath(location)).toBe(trusted);
});
});
17 changes: 17 additions & 0 deletions components/dashboard/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,3 +213,20 @@ export class ReplayableEventEmitter<EventTypes extends EventMap> extends EventEm
return this.reachedEnd;
}
}

function parseUrl(url: string): URL | null {
try {
return new URL(url);
} catch (_) {
return null;
}
}

export function isTrustedUrlOrPath(urlOrPath: string) {
const url = parseUrl(urlOrPath);
const isTrusted = url ? window.location.hostname === url.hostname : urlOrPath.startsWith("/");
if (!isTrusted) {
console.warn("Untrusted URL", urlOrPath);
}
return isTrusted;
}
4 changes: 2 additions & 2 deletions components/server/src/oauth-server/oauth-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export class OAuthController {

private getValidUser(req: express.Request, res: express.Response): User | null {
if (!req.isAuthenticated() || !User.is(req.user)) {
const returnToPath = encodeURIComponent(`api${req.originalUrl}`);
const returnToPath = encodeURIComponent(`/api${req.originalUrl}`);
const redirectTo = `${this.config.hostUrl}login?returnToPath=${returnToPath}`;
res.redirect(redirectTo);
return null;
Expand Down Expand Up @@ -102,7 +102,7 @@ export class OAuthController {
if (!oauthClientsApproved || !oauthClientsApproved[clientID]) {
const client = await clientRepository.getByIdentifier(clientID);
if (client) {
const returnToPath = encodeURIComponent(`api${req.originalUrl}`);
const returnToPath = encodeURIComponent(`/api${req.originalUrl}`);
const redirectTo = `${this.config.hostUrl}oauth-approval?clientID=${client.id}&clientName=${client.name}&returnToPath=${returnToPath}`;
res.redirect(redirectTo);
return false;
Expand Down

0 comments on commit b6c2db7

Please sign in to comment.