Skip to content

Commit 5bb65bf

Browse files
eyepokesjimaek
andauthored
redirect URL handling (#23)
* feat: enhance OAuth flow with trusted redirect URI validation and manual confirmation page(7.12.2 section) * feat: refactor redirect URI validation to use isLocalhost helper function * feat: simplify redirect URI validation by removing exception host checks * - reverted exception host validation for trusted domains - removed duplicate crypto functions from UI layer - display only origin instead of full URL in manual confirmation page * Bump version from 0.0.2 to 1.0.0 * Update version and description in gemini-extension.json --------- Co-authored-by: Dmitriy Akulov <dakulovgr@gmail.com>
1 parent 64317a8 commit 5bb65bf

8 files changed

Lines changed: 303 additions & 122 deletions

File tree

gemini-extension.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "globalping",
3-
"version": "0.0.2",
4-
"description": "Enable AI models to interact with a global network measurement platform and run network measurements, benchmarks and tests.",
3+
"version": "1.0.0",
4+
"description": "Interact with a global network of probes and run network measurements, benchmarks and tests.",
55
"mcpServers": {
66
"globalping-mcp": {
77
"httpUrl": "https://mcp.globalping.dev/mcp",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "globalping-mcp-server",
3-
"version": "0.0.2",
3+
"version": "1.0.0",
44
"private": true,
55
"scripts": {
66
"deploy": "wrangler deploy",

src/app.ts

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
*/
44
import { Hono } from "hono";
55
import { html } from "hono/html";
6-
import { layout } from "./ui";
7-
import { createPKCECodes, generateRandomString, isDeepLink, isExceptionHost } from "./lib";
6+
import { layout, manualRedirectPage } from "./ui";
7+
import { createPKCECodes, generateRandomString, isTrustedRedirectUri } from "./lib";
88
import type { AuthRequest, OAuthHelpers } from "@cloudflare/workers-oauth-provider";
99
import type { StateData, GlobalpingEnv, GlobalpingOAuthTokenResponse } from "./types";
1010

@@ -44,7 +44,7 @@ async function getUserData(accessToken: string): Promise<any> {
4444
}
4545

4646
// Root route - redirect to repository
47-
app.get("/", async (c) => {
47+
app.get("/", async (_c) => {
4848
return Response.redirect(GLOBALPING_REPOSITORY_URL);
4949
});
5050

@@ -68,16 +68,23 @@ app.get("/authorize", async (c) => {
6868
);
6969
}
7070

71-
// Validate redirect_uri
72-
if (
73-
`${new URL(c.req.url).origin}/auth/callback` !== oauthReqInfo.redirectUri &&
74-
!/http:\/\/localhost:\d+\/(.*)/is.test(oauthReqInfo.redirectUri) &&
75-
!isDeepLink(oauthReqInfo.redirectUri) &&
76-
!isExceptionHost(oauthReqInfo.redirectUri)
77-
) {
71+
// Basic validation: just check redirect_uri is set and parsable
72+
// The callback endpoint will later decide if auto-redirect or show manual confirmation
73+
if (!oauthReqInfo.redirectUri) {
7874
return c.html(
7975
layout(
80-
await html`<h1>Invalid redirect URI</h1><p>Redirect URI does not match the original request.</p>`,
76+
await html`<h1>Invalid request</h1><p>Missing redirect URI.</p>`,
77+
"Invalid request",
78+
),
79+
);
80+
}
81+
82+
try {
83+
new URL(oauthReqInfo.redirectUri);
84+
} catch (error) {
85+
return c.html(
86+
layout(
87+
await html`<h1>Invalid redirect URI</h1><p>Redirect URI is malformed.</p>`,
8188
"Invalid redirect URI",
8289
),
8390
);
@@ -245,8 +252,19 @@ app.get("/auth/callback", async (c) => {
245252
},
246253
});
247254

248-
// Redirect to the client app
249-
return Response.redirect(redirectTo, 302);
255+
// Per OAuth 2.0 Security Best Practices (RFC 6819 section 7.12.2),
256+
// only automatically redirect to trusted URIs
257+
if (isTrustedRedirectUri(redirectTo)) {
258+
// Auto-redirect for trusted URIs (localhost, deep links)
259+
return Response.redirect(redirectTo, 302);
260+
}
261+
262+
// Show manual confirmation page for untrusted HTTPS URIs
263+
// Extract origin for cleaner display (without query params/paths)
264+
const displayUrl = new URL(redirectTo).origin;
265+
return c.html(
266+
layout(await manualRedirectPage(redirectTo, displayUrl), "Complete Authentication"),
267+
);
250268
} catch (error: any) {
251269
console.error("Token exchange error:", error);
252270
return c.html(

src/config/constants.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,6 @@ export const STANDARD_PROTOCOLS = new Set([
8181
"javascript:",
8282
]);
8383

84-
export const EXCEPTION_HOSTS = new Set([
85-
"playground.ai.cloudflare.com",
86-
"mcp.docker.com",
87-
"mcptotal.io",
88-
]);
89-
9084
export const PKCE_CONFIG = {
9185
CODE_VERIFIER_LENGTH: 64,
9286
HASH_ALGORITHM: "SHA-256",
@@ -97,3 +91,9 @@ export const RANDOM_STRING_CONFIG = {
9791
PAD_LENGTH: 2,
9892
PAD_CHAR: "0",
9993
};
94+
95+
export const EXCEPTION_HOSTS = new Set([
96+
"playground.ai.cloudflare.com",
97+
"mcp.docker.com",
98+
"mcptotal.io",
99+
]);

src/lib/url-validation.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,35 @@ export function isExceptionHost(urlString: string): boolean {
3232
return false;
3333
}
3434
}
35+
36+
/**
37+
* Check if a URL is localhost
38+
* @param urlString The URL to check
39+
* @returns True if URL is localhost
40+
*/
41+
export function isLocalhost(urlString: string): boolean {
42+
try {
43+
const url = new URL(urlString);
44+
const hostname = url.hostname.toLowerCase();
45+
return (
46+
hostname === "localhost" ||
47+
hostname === "127.0.0.1" ||
48+
hostname === "[::1]" ||
49+
hostname.endsWith(".localhost")
50+
);
51+
} catch (err) {
52+
// Invalid URL string
53+
return false;
54+
}
55+
}
56+
57+
/**
58+
* Check if a redirect URI is trusted for automatic redirection
59+
* Per OAuth 2.0 Security Best Practices (RFC 6819 section 7.12.2),
60+
* only certain types of URIs should be automatically redirected to
61+
* @param urlString The redirect URI to check
62+
* @returns True if the URI is trusted for automatic redirection
63+
*/
64+
export function isTrustedRedirectUri(urlString: string): boolean {
65+
return isLocalhost(urlString) || isDeepLink(urlString) || isExceptionHost(urlString);
66+
}

src/ui/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export * from "./layout";
1+
export { layout, manualRedirectPage } from "./layout";

src/ui/layout.ts

Lines changed: 89 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
/**
22
* HTML layout template
33
*/
4-
import type { AuthRequest } from "@cloudflare/workers-oauth-provider";
54
import { html } from "hono/html";
65
import type { HtmlEscapedString } from "hono/utils/html";
7-
import type { PKCECodePair } from "../types";
86

97
export const layout = (content: HtmlEscapedString | string, title: string) => html`
108
<!DOCTYPE html>
@@ -83,6 +81,17 @@ export const layout = (content: HtmlEscapedString | string, title: string) => ht
8381
text-decoration: underline;
8482
}
8583
84+
/* Preserve text-white for button-styled links */
85+
.markdown a.bg-primary,
86+
.markdown a.bg-secondary {
87+
color: white !important;
88+
}
89+
90+
.markdown a.bg-primary:hover,
91+
.markdown a.bg-secondary:hover {
92+
text-decoration: none;
93+
}
94+
8695
.markdown blockquote {
8796
border-left: 4px solid #f39c12;
8897
padding-left: 1rem;
@@ -171,108 +180,90 @@ export const layout = (content: HtmlEscapedString | string, title: string) => ht
171180
</html>
172181
`;
173182

174-
export const parseApproveFormBody = async (body: {
175-
[x: string]: string | File;
176-
}) => {
177-
const action = body.action as string;
178-
const email = body.email as string;
179-
const password = body.password as string;
180-
let oauthReqInfo: AuthRequest | null = null;
181-
try {
182-
oauthReqInfo = JSON.parse(body.oauthReqInfo as string) as AuthRequest;
183-
} catch (e) {
184-
oauthReqInfo = null;
185-
}
186-
187-
return { action, oauthReqInfo, email, password };
188-
};
189-
190183
/**
191-
* Generate a random string for PKCE and state
192-
* @param length Length of the random string
193-
* @returns A URL-safe random string
184+
* Create a manual redirect confirmation page
185+
* Per OAuth 2.0 Security Best Practices (RFC 6819 section 7.12.2),
186+
* untrusted redirect URIs should require manual user confirmation
187+
* @param redirectUri The full redirect URI for the actual redirect
188+
* @param displayUrl The origin to display to the user (cleaner than full URL)
189+
* @returns HTML string for the confirmation page
194190
*/
195-
export function generateRandomString(length: number): string {
196-
const array = new Uint8Array(length);
197-
crypto.getRandomValues(array);
198-
return Array.from(array)
199-
.map((b) => b.toString(16).padStart(2, "0"))
200-
.join("")
201-
.substring(0, length);
202-
}
191+
export const manualRedirectPage = (redirectUri: string, displayUrl: string) => html`
192+
<div class="markdown max-w-2xl mx-auto">
193+
<div class="bg-white rounded-lg shadow-md p-8 border-l-4 border-accent">
194+
<div class="mb-6">
195+
<svg
196+
class="w-16 h-16 mx-auto text-accent"
197+
fill="none"
198+
stroke="currentColor"
199+
viewBox="0 0 24 24"
200+
xmlns="http://www.w3.org/2000/svg"
201+
>
202+
<path
203+
stroke-linecap="round"
204+
stroke-linejoin="round"
205+
stroke-width="2"
206+
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
207+
></path>
208+
</svg>
209+
</div>
203210
204-
/**
205-
* Create a code verifier and code challenge pair for PKCE
206-
* @returns A code verifier and challenge pair
207-
*/
208-
export async function createPKCECodes(): Promise<PKCECodePair> {
209-
// Generate code verifier (random string between 43-128 chars)
210-
const codeVerifier = generateRandomString(64);
211+
<h1 class="text-center mb-4">Authentication successful</h1>
211212
212-
// Create code challenge using SHA-256
213-
const encoder = new TextEncoder();
214-
const data = encoder.encode(codeVerifier);
215-
const digest = await crypto.subtle.digest("SHA-256", data);
213+
<div class="bg-yellow-50 border-l-4 border-yellow-400 p-4 mb-6">
214+
<div class="flex">
215+
<div class="flex-shrink-0">
216+
<svg
217+
class="h-5 w-5 text-yellow-400"
218+
viewBox="0 0 20 20"
219+
fill="currentColor"
220+
>
221+
<path
222+
fill-rule="evenodd"
223+
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
224+
clip-rule="evenodd"
225+
/>
226+
</svg>
227+
</div>
228+
<div class="ml-3">
229+
<p class="text-sm text-yellow-700 font-medium">
230+
Security notice
231+
</p>
232+
</div>
233+
</div>
234+
</div>
216235
217-
// Convert digest to base64url format
218-
const base64Digest = btoa(String.fromCharCode(...new Uint8Array(digest)))
219-
.replace(/\+/g, "-")
220-
.replace(/\//g, "_")
221-
.replace(/=/g, "");
236+
<p class="mb-4">
237+
Your authentication was successful. However, for security reasons, you
238+
need to manually complete the redirect to the following URL:
239+
</p>
222240
223-
return {
224-
codeVerifier,
225-
codeChallenge: base64Digest,
226-
};
227-
}
241+
<div class="bg-gray-100 p-4 rounded-md mb-6 break-all">
242+
<code class="text-sm text-gray-800">${displayUrl}</code>
243+
</div>
228244
229-
const STANDARD_PROTOCOLS = new Set([
230-
"http:",
231-
"https:",
232-
"ftp:",
233-
"file:",
234-
"mailto:",
235-
"tel:",
236-
"ws:",
237-
"wss:",
238-
"sms:",
239-
"data:",
240-
"blob:",
241-
"about:",
242-
"chrome:",
243-
"opera:",
244-
"edge:",
245-
"safari:",
246-
"javascript:",
247-
]);
245+
<div class="bg-blue-50 border-l-4 border-blue-400 p-4 mb-6">
246+
<p class="text-sm text-blue-700">
247+
<strong>Why do I need to click?</strong> As a security measure, this
248+
authorization server requires manual confirmation before redirecting
249+
to third-party websites. This helps protect you from potential
250+
phishing attacks.
251+
</p>
252+
</div>
248253
249-
/**
250-
* Check if a URL is a deep link
251-
* @param url The URL to check
252-
* @returns
253-
*/
254-
export function isDeepLink(url: string): boolean {
255-
try {
256-
const parsedUrl = new URL(url);
257-
const protocol = parsedUrl.protocol.toLowerCase();
258-
return !STANDARD_PROTOCOLS.has(protocol);
259-
} catch (e) {
260-
return false;
261-
}
262-
}
254+
<div class="text-center">
255+
<a
256+
href="${redirectUri}"
257+
class="inline-block bg-primary hover:bg-primary/90 text-white font-semibold py-3 px-8 rounded-lg transition-colors duration-200 shadow-md hover:shadow-lg"
258+
>
259+
Click here to complete authentication
260+
</a>
261+
</div>
263262
264-
export function isExceptionHost(urlString: string): boolean {
265-
try {
266-
const url = new URL(urlString);
267-
const exceptionHosts = new Set([
268-
"playground.ai.cloudflare.com",
269-
"mcp.docker.com",
270-
"mcptotal.io",
271-
// add more exception hosts here if needed
272-
]);
273-
return exceptionHosts.has(url.hostname);
274-
} catch (err) {
275-
// Invalid URL string
276-
return false;
277-
}
278-
}
263+
<p class="text-sm text-gray-500 mt-6 text-center">
264+
Only click the button above if you trust the destination and initiated
265+
this authentication request.
266+
</p>
267+
</div>
268+
</div>
269+
`;

0 commit comments

Comments
 (0)