From 6d54ac629e763a0cdd665cde44a4b1c18eb04a37 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 4 Sep 2026 12:15:53 +0200 Subject: [PATCH] fix(oauth): accept private-use redirect schemes and loopback ports POST /oauth/register refused every single-label private-use scheme, so an MCP client registering workbuddy:// could not connect at all, while the error it got back advertised "a private custom scheme" as supported. The cause was two validators that had drifted apart, both of which run on registration. The provider required a dot in the scheme name, which RFC 8252 section 7.1 only recommends and which protected nothing (javascript.evil:// passed it). The service behind it had no custom-scheme branch whatsoever and no [::1], which is what actually answered the reporter's second symptom, and its localhost exemption was not gated on http:. So javascript://localhost/%0aalert(1), blob:, about:, ftp: and wss: were all accepted through the settings UI, and the consent flow assigns the stored URI to window.location.href. Only the CSP stood between that and a navigation. Both now decide through one policy in oauth.helpers.ts. Widening the scheme set means the deny-list has to carry its own weight: it grows by the browser, extension and OS-handler schemes that were previously blocked only as a side effect of the dot rule, including ms-msdt (Follina) and the ms- namespace by prefix. intent: is denied, because Chrome follows its embedded fallback URL, which turns it into an open redirect the authorization server cannot inspect. Fixing registration alone would have moved the reporter's [::1]:{port} failure one step later: TREK matched redirect URIs byte for byte while RFC 8252 section 7.3 frees the port for loopback, so a native client on an OS-assigned port got a 302 to consent and then invalid_redirect_uri. The matcher now relaxes the port, and only the port, for http loopback. Stored URIs are never canonicalised, so the %3A round-trips untouched. Note this tightens POST /api/oauth/clients for inputs it accepts today. That is the security half of the change and applies at create time only, so no already-registered client changes behaviour. Closes #2227 --- server/src/nest/oauth/oauth-sdk.provider.ts | 24 ++-- server/src/nest/oauth/oauth.helpers.ts | 110 ++++++++++++++ server/src/nest/oauth/oauth.service.ts | 26 ++-- .../tests/integration/oauth-authorize.test.ts | 21 +++ server/tests/integration/oauth.test.ts | 91 ++++++++++++ .../unit/nest/oauth-redirect-uri.test.ts | 134 ++++++++++++++++++ .../unit/nest/oauth-sdk.provider.test.ts | 27 +++- server/tests/unit/nest/oauth.service.test.ts | 60 ++++++++ shared/src/i18n/ar/settings.ts | 3 +- shared/src/i18n/br/settings.ts | 2 +- shared/src/i18n/ca/settings.ts | 2 +- shared/src/i18n/cs/settings.ts | 2 +- shared/src/i18n/de/settings.ts | 2 +- shared/src/i18n/en/settings.ts | 3 +- shared/src/i18n/es/settings.ts | 2 +- shared/src/i18n/fr/settings.ts | 2 +- shared/src/i18n/gr/settings.ts | 2 +- shared/src/i18n/hu/settings.ts | 2 +- shared/src/i18n/id/settings.ts | 2 +- shared/src/i18n/it/settings.ts | 2 +- shared/src/i18n/ja/settings.ts | 3 +- shared/src/i18n/ko/settings.ts | 3 +- shared/src/i18n/nl/settings.ts | 2 +- shared/src/i18n/pl/settings.ts | 2 +- shared/src/i18n/ru/settings.ts | 2 +- shared/src/i18n/sv/settings.ts | 2 +- shared/src/i18n/tr/settings.ts | 2 +- shared/src/i18n/uk/settings.ts | 2 +- shared/src/i18n/vi/settings.ts | 2 +- shared/src/i18n/zh-TW/settings.ts | 3 +- shared/src/i18n/zh/settings.ts | 3 +- 31 files changed, 492 insertions(+), 53 deletions(-) create mode 100644 server/tests/unit/nest/oauth-redirect-uri.test.ts diff --git a/server/src/nest/oauth/oauth-sdk.provider.ts b/server/src/nest/oauth/oauth-sdk.provider.ts index b304cf77c..c5b9b9c2a 100644 --- a/server/src/nest/oauth/oauth-sdk.provider.ts +++ b/server/src/nest/oauth/oauth-sdk.provider.ts @@ -6,6 +6,7 @@ import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types'; import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients'; import { InvalidClientMetadataError, ServerError } from '@modelcontextprotocol/sdk/server/auth/errors'; import { OauthService } from './oauth.service'; +import { classifyRedirectUri } from './oauth.helpers'; import { AuditService } from '../audit/audit.service'; import { ALL_SCOPES, DEFAULT_CLIENT_SCOPES } from '../../mcp/scopes'; import { getMcpSafeUrl } from '../../app-config'; @@ -21,26 +22,21 @@ import { getMcpSafeUrl } from '../../app-config'; */ // --------------------------------------------------------------------------- -// Redirect URI validation (mirrors oauth.ts DCR checks) +// Redirect URI validation // --------------------------------------------------------------------------- -const DANGEROUS_SCHEMES = new Set([ - 'javascript:', 'data:', 'vbscript:', 'file:', 'blob:', 'about:', 'chrome:', 'chrome-extension:', -]); - +// DCR runs this and then createOAuthClient's own check; both now decide through +// the one policy in oauth.helpers, which is what closes #2227. The three error +// strings stay byte-identical: DCR clients surface error_description verbatim. function assertValidRedirectUris(uris: string[]): void { for (const u of uris) { - let url: URL; - try { url = new URL(u); } catch { + const verdict = classifyRedirectUri(u); + if (verdict === 'malformed') throw new InvalidClientMetadataError(`Invalid redirect URI: ${u}`); - } - if (DANGEROUS_SCHEMES.has(url.protocol)) + if (verdict === 'dangerous') throw new InvalidClientMetadataError(`Dangerous redirect URI scheme: ${u}`); - if (url.protocol === 'https:') continue; - if (url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]')) continue; - const scheme = url.protocol.slice(0, -1); - if (/^[a-z][a-z0-9+.-]*$/i.test(scheme) && scheme.includes('.')) continue; - throw new InvalidClientMetadataError('redirect_uris must be HTTPS, loopback HTTP, or a private custom scheme'); + if (verdict === 'not_allowed') + throw new InvalidClientMetadataError('redirect_uris must be HTTPS, loopback HTTP, or a private custom scheme'); } } diff --git a/server/src/nest/oauth/oauth.helpers.ts b/server/src/nest/oauth/oauth.helpers.ts index 49933506f..2d87b2c05 100644 --- a/server/src/nest/oauth/oauth.helpers.ts +++ b/server/src/nest/oauth/oauth.helpers.ts @@ -36,6 +36,116 @@ export function parseSqliteUtc(ts: string | null | undefined): Date | null { export const CODE_CHALLENGE_RE = /^[A-Za-z0-9_-]{43}$/; export const CODE_VERIFIER_RE = /^[A-Za-z0-9\-._~]{43,128}$/; +// --------------------------------------------------------------------------- +// Redirect URI validation (RFC 8252) +// --------------------------------------------------------------------------- + +/** + * The three loopback hosts of RFC 8252 §7.3, and exactly the set the MCP SDK's + * authorize handler matches on. Registration and authorization have to agree on + * this list or a client registers a URI it can never authorize with (#2227). + */ +export const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); + +/** + * Schemes a browser must never be redirected to: script-executing + * (javascript:, vbscript:, data:), local-resource (file:, filesystem:, + * resource:, jar:, view-source:, blob:, about:), and extension or OS handlers + * that reach code outside the page (chrome-extension:, moz-extension:, + * safari-extension:, ms-msdt:, which is the Follina handler). + * + * Most of these were only ever blocked as a side effect of the old "a custom + * scheme must contain a dot" rule; dropping that rule to admit workbuddy:// + * (#2227) would otherwise have admitted them too. `intent:` is denied with + * them: Chrome follows its S.browser_fallback_url parameter, which turns any + * accepted intent:// redirect into an open redirect. Android apps that need a + * redirect register a private-use scheme or an https App Link instead. + */ +const DANGEROUS_REDIRECT_SCHEMES = new Set([ + 'javascript:', 'data:', 'vbscript:', 'file:', 'blob:', 'about:', 'chrome:', 'chrome-extension:', + 'view-source:', 'filesystem:', 'resource:', 'jar:', 'moz-extension:', 'safari-extension:', + 'chrome-search:', 'chrome-untrusted:', 'ms-browser-extension:', 'ms-msdt:', 'android-app:', + 'intent:', 'search-ms:', 'shell:', 'microsoft-edge:', 'itms-services:', +]); + +/** + * Everything under `ms-` is a Windows protocol handler (ms-settings:, + * ms-search:, ms-officecmd:, …), never a redirect target, and the namespace + * grows with every Windows release, so deny the prefix rather than chase it. + * Microsoft's own identity redirects use msauth:/msal…: and stay allowed. + */ +const DANGEROUS_SCHEME_PREFIX = 'ms-'; + +/** Transports that are not browser navigations, so never a redirect target. */ +const NON_REDIRECT_SCHEMES = new Set([ + 'ws:', 'wss:', 'ftp:', 'ftps:', 'mailto:', 'tel:', 'sms:', 'urn:', +]); + +export type RedirectUriVerdict = 'ok' | 'malformed' | 'dangerous' | 'not_allowed'; + +/** + * The single redirect_uri policy for both registration paths, DCR + * (POST /oauth/register) and the settings UI (POST /api/oauth/clients), which + * used to disagree: DCR refused every single-label private-use scheme while the + * settings route accepted javascript://localhost/… (#2227). + * + * Decisions are made on `url.protocol`, never on the raw string: the WHATWG + * parser lowercases the scheme and strips tabs, newlines and leading spaces, so + * 'JavaScript:', 'java\tscript:' and ' javascript:' all normalise into the + * deny-list instead of sliding past it. + */ +export function classifyRedirectUri(uri: string): RedirectUriVerdict { + let url: URL; + try { url = new URL(uri); } catch { return 'malformed'; } + + const protocol = url.protocol; + if (DANGEROUS_REDIRECT_SCHEMES.has(protocol) || protocol.startsWith(DANGEROUS_SCHEME_PREFIX)) return 'dangerous'; + if (NON_REDIRECT_SCHEMES.has(protocol)) return 'not_allowed'; + if (protocol === 'https:') return 'ok'; + if (protocol === 'http:') return LOOPBACK_HOSTS.has(url.hostname) ? 'ok' : 'not_allowed'; + + // Private-use schemes (RFC 8252 §7.1). The reverse-domain form is a SHOULD, + // not a MUST, and requiring a dot bought nothing: 'javascript.evil://x' + // passed it while 'workbuddy://…' did not. Two characters minimum so a + // Windows path (c:\tmp\cb) is not read as a scheme. + const scheme = protocol.slice(0, -1); + return /^[a-z][a-z0-9+.-]+$/i.test(scheme) ? 'ok' : 'not_allowed'; +} + +/** + * Byte-exact, with the one relaxation RFC 8252 §7.3 requires: a native client + * registers a loopback URI with a placeholder port and then authorizes on the + * port the OS handed it, so the port (and only the port) is ignored when both + * sides are loopback HTTP. The MCP SDK's authorize handler already relaxes it; + * TREK's matcher did not, so the SDK redirected to consent and the validate + * route then answered invalid_redirect_uri for the same request (#2227). + * + * Nothing else is normalised anywhere in the chain: the stored URI is compared + * as it was registered, percent-encoding included. + */ +export function redirectUriMatches(allowed: string, requested: string): boolean { + if (allowed === requested) return true; + + let a: URL, r: URL; + try { + a = new URL(allowed); + r = new URL(requested); + } catch { return false; } + + if (a.protocol !== 'http:' || r.protocol !== 'http:') return false; + if (!LOOPBACK_HOSTS.has(a.hostname) || !LOOPBACK_HOSTS.has(r.hostname)) return false; + // Only the port is free. The RFC does not allow localhost↔127.0.0.1 either, and + // userinfo and fragment are compared too: the URL parser would otherwise drop + // them silently and let 'http://attacker@127.0.0.1:1/cb#x' match a bare + // 'http://127.0.0.1:8080/cb'. + return a.hostname === r.hostname + && a.pathname === r.pathname + && a.search === r.search + && a.hash === r.hash + && a.username === r.username + && a.password === r.password; +} + // --------------------------------------------------------------------------- // DB row types // --------------------------------------------------------------------------- diff --git a/server/src/nest/oauth/oauth.service.ts b/server/src/nest/oauth/oauth.service.ts index 98f3e76de..972f189a5 100644 --- a/server/src/nest/oauth/oauth.service.ts +++ b/server/src/nest/oauth/oauth.service.ts @@ -20,10 +20,12 @@ import { CODE_VERIFIER_RE, REFRESH_ROTATION_GRACE_MS, REFRESH_TOKEN_TTL_MS, + classifyRedirectUri, generateAccessToken, generateRefreshToken, hashToken, parseSqliteUtc, + redirectUriMatches, timingSafeEqualHex, type OAuthClientRow, type OAuthTokenRow, @@ -118,16 +120,15 @@ export class OauthService { if (!isMachineClient && (!redirectUris || redirectUris.length === 0)) return { error: 'At least one redirect URI is required', status: 400 }; if (redirectUris.length > 10) return { error: 'Maximum 10 redirect URIs per client', status: 400 }; + // Same policy as the DCR path (#2227). This used to exempt any host named + // localhost or 127.0.0.1 regardless of scheme, which let the settings UI + // register javascript://localhost/…, and the consent flow then assigns that + // stored URI to window.location.href. for (const uri of redirectUris) { - let parsed: URL; - try { - parsed = new URL(uri); - } catch { - return { error: `Invalid redirect URI: ${uri}`, status: 400 }; - } - if (parsed.protocol !== 'https:' && parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') { - return { error: `Redirect URI must use HTTPS (localhost exempt): ${uri}`, status: 400 }; - } + const verdict = classifyRedirectUri(uri); + if (verdict === 'malformed') return { error: `Invalid redirect URI: ${uri}`, status: 400 }; + if (verdict === 'dangerous') return { error: `Dangerous redirect URI scheme: ${uri}`, status: 400 }; + if (verdict === 'not_allowed') return { error: `Redirect URI must use HTTPS, loopback HTTP, or a private custom scheme: ${uri}`, status: 400 }; } if (!allowedScopes || allowedScopes.length === 0) return { error: 'At least one scope is required', status: 400 }; @@ -615,7 +616,12 @@ export class OauthService { } const allowedUris: string[] = JSON.parse(client.redirect_uris); - if (!params.redirect_uri || !allowedUris.includes(params.redirect_uri)) { + // Exact match except for the loopback port, which RFC 8252 §7.3 leaves to + // the OS, and which the SDK's authorize handler already relaxes, so a + // native client got a 302 to consent and an invalid_redirect_uri from this + // route for one and the same request (#2227). + const requestedUri = params.redirect_uri; + if (!requestedUri || !allowedUris.some(allowed => redirectUriMatches(allowed, requestedUri))) { return { valid: false, error: 'invalid_redirect_uri', error_description: 'redirect_uri does not match any registered URI' }; } diff --git a/server/tests/integration/oauth-authorize.test.ts b/server/tests/integration/oauth-authorize.test.ts index 3217e8f5f..c0fd8a886 100644 --- a/server/tests/integration/oauth-authorize.test.ts +++ b/server/tests/integration/oauth-authorize.test.ts @@ -161,6 +161,27 @@ describe('GET /oauth/authorize — SDK authorizationHandler over trekOAuthProvid expect(loc.searchParams.get('state')).toBeNull(); }); + it('AUTHZ-002B: a private-use scheme registers and authorizes with the byte-identical URI (#2227)', async () => { + const redirectUri = 'workbuddy://workbuddy/mcp/connector%3A/oauth/callback'; + const clientId = await registerClient(redirectUri); + const { challenge } = makePkce(); + + const res = await request(app).get('/oauth/authorize').query({ + response_type: 'code', + client_id: clientId, + redirect_uri: redirectUri, + scope: 'trips:read', + code_challenge: challenge, + code_challenge_method: 'S256', + }); + + expect(res.status).toBe(302); + const loc = new URL(res.headers.location); + expect(`${loc.origin}${loc.pathname}`).toBe('https://trek.example.com/oauth/consent'); + // Forwarded unchanged: the %3A must survive to the token exchange. + expect(loc.searchParams.get('redirect_uri')).toBe(redirectUri); + }); + it('AUTHZ-003 — wrong resource 302-redirects back to the client with invalid_target', async () => { const clientId = await registerClient(); const { challenge } = makePkce(); diff --git a/server/tests/integration/oauth.test.ts b/server/tests/integration/oauth.test.ts index 30f8725a1..8d0b61a1d 100644 --- a/server/tests/integration/oauth.test.ts +++ b/server/tests/integration/oauth.test.ts @@ -165,6 +165,43 @@ describe('DCR scope optional — ChatGPT compatibility (issue #959 bug 2)', () = }); }); +// DCR runs two validators in sequence: the clients store's own check and +// createOAuthClient's. Every unit test of the first one stubs the second away, +// which is how both of these shipped rejected (#2227). +describe('DCR redirect URI policy (issue #2227)', () => { + it('OAUTH-2227A: POST /oauth/register accepts the [::1] loopback URI', async () => { + const res = await request(app) + .post('/oauth/register') + .set('Content-Type', 'application/json') + .send({ redirect_uris: ['http://[::1]:8080/oauth/callback'], token_endpoint_auth_method: 'none', scope: 'trips:read' }); + expect(res.status).toBe(201); + expect(res.body.redirect_uris).toEqual(['http://[::1]:8080/oauth/callback']); + }); + + it('OAUTH-2227B: POST /oauth/register accepts a single-label private-use scheme, unmodified', async () => { + const uri = 'workbuddy://workbuddy/mcp/connector%3A/oauth/callback'; + const res = await request(app) + .post('/oauth/register') + .set('Content-Type', 'application/json') + .send({ redirect_uris: [uri], token_endpoint_auth_method: 'none', scope: 'trips:read' }); + expect(res.status).toBe(201); + // Stored verbatim: the authorize and token endpoints compare it byte for byte. + expect(res.body.redirect_uris).toEqual([uri]); + }); + + // The SDK's own metadata schema only blocks javascript:, data: and vbscript:, + // so these two are TREK's to refuse, one per verdict branch. + it('OAUTH-2227C: POST /oauth/register still refuses dangerous and non-navigable schemes on a loopback host', async () => { + for (const uri of ['blob://localhost/x', 'ws://localhost/cb']) { + const res = await request(app) + .post('/oauth/register') + .set('Content-Type', 'application/json') + .send({ redirect_uris: [uri], token_endpoint_auth_method: 'none', scope: 'trips:read' }); + expect(res.status).toBe(400); + } + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // Platform/discovery parity pins — byte-level oracles for the MCP/OAuth mount // migration (these must pass identically before AND after the routes move @@ -641,6 +678,60 @@ describe('GET /api/oauth/authorize/validate', () => { expect(res.body.error).toBe('unsupported_response_type'); }); + it('OAUTH-2227D: a loopback client that registered a placeholder port validates on the port the OS gave it', async () => { + // The reporter's shape end to end: register http://[::1]:8080/oauth/callback, + // then authorize from the ephemeral port the OS actually handed the client. + // Before #2227 registration refused the URI outright; once it was accepted, + // this route still answered invalid_redirect_uri because it compared byte + // for byte while RFC 8252 §7.3 frees the port for loopback redirects. + const { user } = createUser(testDb); + const { challenge } = makePkce(); + const reg = await request(app) + .post('/oauth/register') + .set('Content-Type', 'application/json') + .send({ redirect_uris: ['http://[::1]:8080/oauth/callback'], token_endpoint_auth_method: 'none', scope: 'trips:read' }); + expect(reg.status).toBe(201); + + const res = await request(app) + .get('/api/oauth/authorize/validate') + .set('Cookie', authCookie(user.id)) + .query({ + response_type: 'code', + client_id: reg.body.client_id, + redirect_uri: 'http://[::1]:54321/oauth/callback', + scope: 'trips:read', + code_challenge: challenge, + code_challenge_method: 'S256', + }); + expect(res.status).toBe(200); + expect(res.body.valid).toBe(true); + }); + + it('OAUTH-2227E: the port is all that is free, so a different callback path is still refused', async () => { + const { user } = createUser(testDb); + const { challenge } = makePkce(); + const reg = await request(app) + .post('/oauth/register') + .set('Content-Type', 'application/json') + .send({ redirect_uris: ['http://127.0.0.1:8080/oauth/callback'], token_endpoint_auth_method: 'none', scope: 'trips:read' }); + expect(reg.status).toBe(201); + + const res = await request(app) + .get('/api/oauth/authorize/validate') + .set('Cookie', authCookie(user.id)) + .query({ + response_type: 'code', + client_id: reg.body.client_id, + redirect_uri: 'http://127.0.0.1:54321/stolen', + scope: 'trips:read', + code_challenge: challenge, + code_challenge_method: 'S256', + }); + expect(res.status).toBe(200); + expect(res.body.valid).toBe(false); + expect(res.body.error).toBe('invalid_redirect_uri'); + }); + it('OAUTH-021 — returns 200 with valid:false for missing PKCE', async () => { const res = await request(app) .get('/api/oauth/authorize/validate') diff --git a/server/tests/unit/nest/oauth-redirect-uri.test.ts b/server/tests/unit/nest/oauth-redirect-uri.test.ts new file mode 100644 index 000000000..41238befb --- /dev/null +++ b/server/tests/unit/nest/oauth-redirect-uri.test.ts @@ -0,0 +1,134 @@ +/** + * The shared redirect_uri policy (#2227). Both registration paths (DCR through + * the SDK clients store and the settings UI through createOAuthClient) used to + * carry their own copy of it and disagreed in both directions: DCR refused every + * single-label private-use scheme, the settings route accepted + * javascript://localhost/…. These cases pin the one policy they now share. + */ +import { describe, it, expect } from 'vitest'; +import { classifyRedirectUri, redirectUriMatches, type RedirectUriVerdict } from '../../../src/nest/oauth/oauth.helpers'; + +describe('classifyRedirectUri', () => { + const cases: Array<[string, RedirectUriVerdict]> = [ + // https and loopback http + ['https://a.example.com/cb', 'ok'], + ['https://a.example.com:8443/cb?x=1#f', 'ok'], + ['http://localhost:8080/cb', 'ok'], + ['http://127.0.0.1/cb', 'ok'], + ['http://[::1]/cb', 'ok'], + ['http://[::1]:8080/oauth/callback', 'ok'], + ['http://evil.example.com/cb', 'not_allowed'], + ['http://127.0.0.1.evil.com/cb', 'not_allowed'], + + // private-use schemes, with and without the reverse-domain form + ['com.example.app:/oauth2redirect', 'ok'], + ['workbuddy://workbuddy/mcp/connector%3A/oauth/callback', 'ok'], + ['myapp://cb', 'ok'], + ['msauth://com.x.y/abc', 'ok'], + + // dangerous schemes, including the ones a localhost host used to smuggle past + // the settings route's hostname exemption + ['javascript:alert(1)', 'dangerous'], + ['javascript://localhost/%0aalert(1)', 'dangerous'], + ['blob://localhost/x', 'dangerous'], + ['about://localhost/x', 'dangerous'], + ['data:text/html,', 'dangerous'], + ['vbscript:msgbox(1)', 'dangerous'], + ['file:///etc/passwd', 'dangerous'], + ['view-source:https://x', 'dangerous'], + ['filesystem:https://x/temporary/y', 'dangerous'], + ['jar:https://x!/y', 'dangerous'], + ['resource://x/y', 'dangerous'], + ['chrome://settings', 'dangerous'], + ['chrome-extension://abc/page.html', 'dangerous'], + ['chrome-untrusted://x/y', 'dangerous'], + ['moz-extension://a/b', 'dangerous'], + ['safari-extension://a/b', 'dangerous'], + ['android-app://com.x.y/https/host/path', 'dangerous'], + ['intent://x#Intent;S.browser_fallback_url=https%3A%2F%2Fevil.test;end', 'dangerous'], + // ms-msdt is Follina; the prefix rule covers the rest of the Windows handlers + ['ms-msdt:/id PCWDiagnostic', 'dangerous'], + ['ms-browser-extension://a/b', 'dangerous'], + ['ms-settings:windowsupdate', 'dangerous'], + ['ms-officecmd://x', 'dangerous'], + + // transports that are not browser navigations + ['ws://localhost/cb', 'not_allowed'], + ['wss://localhost/cb', 'not_allowed'], + ['ftp://localhost/x', 'not_allowed'], + ['ftps://localhost/x', 'not_allowed'], + ['mailto:me@example.com', 'not_allowed'], + ['tel:+15550000', 'not_allowed'], + ['sms:+15550000', 'not_allowed'], + ['urn:ietf:wg:oauth:2.0:oob', 'not_allowed'], + + // malformed + ['not a url', 'malformed'], + ['', 'malformed'], + ['/relative/cb', 'malformed'], + ]; + + it.each(cases)('RURI-001: %s → %s', (uri, expected) => { + expect(classifyRedirectUri(uri)).toBe(expected); + }); + + it('RURI-002: decides on the parsed protocol, so case and whitespace tricks normalise into the deny-list', () => { + for (const uri of ['JavaScript:alert(1)', 'java\tscript:alert(1)', ' javascript:alert(1)', 'JAVASCRIPT:alert(1)']) { + expect(classifyRedirectUri(uri)).toBe('dangerous'); + } + }); + + it('RURI-004: OS protocol handlers are denied whether or not they carry the ms- prefix', () => { + // The prefix rule catches the ms-* namespace; these four are the well-known + // siblings that sit outside it and would otherwise read as private-use. + for (const uri of ['search-ms:query=x', 'shell:startup', 'microsoft-edge:https://evil.test', 'itms-services://?url=x']) { + expect(classifyRedirectUri(uri)).toBe('dangerous'); + } + // Microsoft's own identity redirects have no hyphen and stay allowed. + expect(classifyRedirectUri('msauth://com.x.y/abc')).toBe('ok'); + }); + + it('RURI-003: a single-letter scheme is not a private-use scheme', () => { + // 'c:\\tmp\\cb' parses with protocol 'c:', which is a Windows path, not a redirect. + expect(classifyRedirectUri('c:\\tmp\\cb')).toBe('not_allowed'); + }); +}); + +describe('redirectUriMatches', () => { + it('RURI-010: byte-exact matches win, including percent-encoding', () => { + const uri = 'workbuddy://workbuddy/mcp/connector%3A/oauth/callback'; + expect(redirectUriMatches(uri, uri)).toBe(true); + expect(redirectUriMatches(uri, 'workbuddy://workbuddy/mcp/connector:/oauth/callback')).toBe(false); + expect(redirectUriMatches('https://a.example.com/cb', 'https://a.example.com/cb/')).toBe(false); + }); + + it('RURI-011: a loopback redirect may land on any port (RFC 8252 §7.3)', () => { + expect(redirectUriMatches('http://127.0.0.1:8080/callback', 'http://127.0.0.1:54321/callback')).toBe(true); + expect(redirectUriMatches('http://[::1]:8080/oauth/callback', 'http://[::1]:54321/oauth/callback')).toBe(true); + expect(redirectUriMatches('http://localhost/cb?x=1', 'http://localhost:9999/cb?x=1')).toBe(true); + }); + + it('RURI-012: only the port is free, so path, query and host still match exactly', () => { + expect(redirectUriMatches('http://127.0.0.1:8080/callback', 'http://127.0.0.1:54321/other')).toBe(false); + expect(redirectUriMatches('http://127.0.0.1:8080/cb?x=1', 'http://127.0.0.1:54321/cb?x=2')).toBe(false); + // The RFC does not allow localhost↔127.0.0.1 cross-matching. + expect(redirectUriMatches('http://localhost:8080/cb', 'http://127.0.0.1:54321/cb')).toBe(false); + }); + + it('RURI-013: non-loopback and non-http URIs stay byte-exact', () => { + expect(redirectUriMatches('https://a.example.com:8443/cb', 'https://a.example.com:9443/cb')).toBe(false); + expect(redirectUriMatches('http://evil.example.com:80/cb', 'http://evil.example.com:81/cb')).toBe(false); + expect(redirectUriMatches('https://localhost:8443/cb', 'https://localhost:9443/cb')).toBe(false); + expect(redirectUriMatches('myapp://localhost:1/cb', 'myapp://localhost:2/cb')).toBe(false); + }); + + it('RURI-015: userinfo and fragment are compared, not silently dropped', () => { + expect(redirectUriMatches('http://127.0.0.1:8080/cb', 'http://attacker@127.0.0.1:54321/cb')).toBe(false); + expect(redirectUriMatches('http://127.0.0.1:8080/cb', 'http://127.0.0.1:54321/cb#evil')).toBe(false); + }); + + it('RURI-014: an unparseable side never matches', () => { + expect(redirectUriMatches('not a url', 'http://127.0.0.1:1/cb')).toBe(false); + expect(redirectUriMatches('http://127.0.0.1:1/cb', 'not a url')).toBe(false); + }); +}); diff --git a/server/tests/unit/nest/oauth-sdk.provider.test.ts b/server/tests/unit/nest/oauth-sdk.provider.test.ts index df707575b..06431e885 100644 --- a/server/tests/unit/nest/oauth-sdk.provider.test.ts +++ b/server/tests/unit/nest/oauth-sdk.provider.test.ts @@ -141,23 +141,38 @@ describe('TrekClientsStore.registerClient', () => { it('SDKP-011: rejects dangerous schemes', async () => { await expect(store().registerClient({ redirect_uris: ['javascript:alert(1)'] } as never)) .rejects.toThrowError(new InvalidClientMetadataError('Dangerous redirect URI scheme: javascript:alert(1)')); + // A host named localhost does not make the scheme safe (#2227). + for (const uri of ['javascript://localhost/%0aalert(1)', 'blob://localhost/x', 'about://localhost/x']) { + await expect(store().registerClient({ redirect_uris: [uri] } as never)) + .rejects.toThrowError(new InvalidClientMetadataError(`Dangerous redirect URI scheme: ${uri}`)); + } }); - it('SDKP-012: rejects plain-http non-loopback and dot-less custom schemes', async () => { + it('SDKP-012: rejects plain-http non-loopback and non-navigable schemes', async () => { const msg = 'redirect_uris must be HTTPS, loopback HTTP, or a private custom scheme'; - await expect(store().registerClient({ redirect_uris: ['http://evil.example.com/cb'] } as never)) - .rejects.toThrowError(new InvalidClientMetadataError(msg)); - await expect(store().registerClient({ redirect_uris: ['myapp://cb'] } as never)) - .rejects.toThrowError(new InvalidClientMetadataError(msg)); + for (const uri of ['http://evil.example.com/cb', 'ws://localhost/cb']) { + await expect(store().registerClient({ redirect_uris: [uri] } as never)) + .rejects.toThrowError(new InvalidClientMetadataError(msg)); + } + // These were only ever refused as a side effect of the old dot rule. + for (const uri of ['view-source:https://x', 'moz-extension://a/b']) { + await expect(store().registerClient({ redirect_uris: [uri] } as never)) + .rejects.toThrowError(new InvalidClientMetadataError(`Dangerous redirect URI scheme: ${uri}`)); + } }); - it('SDKP-013: accepts https, loopback http, and reverse-DNS custom schemes', async () => { + it('SDKP-013: accepts https, loopback http, and private-use custom schemes', async () => { for (const uri of [ 'https://a.example.com/cb', 'http://localhost:8080/cb', 'http://127.0.0.1/cb', 'http://[::1]/cb', 'com.example.app:/oauth', + // #2227: single-label private-use schemes are legal (RFC 8252 §7.1 only + // SHOULDs the reverse-domain form) and were rejected outright. + 'workbuddy://workbuddy/mcp/connector%3A/oauth/callback', + 'myapp://cb', + 'msauth://com.x.y/abc', ]) { const oauth = makeOauth(); await new TrekClientsStore(oauth).registerClient({ diff --git a/server/tests/unit/nest/oauth.service.test.ts b/server/tests/unit/nest/oauth.service.test.ts index a92654af9..9d59c6df8 100644 --- a/server/tests/unit/nest/oauth.service.test.ts +++ b/server/tests/unit/nest/oauth.service.test.ts @@ -207,6 +207,41 @@ describe('createOAuthClient', () => { expect(result.client).toBeDefined(); }); + it('allows the http://[::1] loopback redirect URI (#2227)', () => { + const { user } = createUser(testDb); + const result = createOAuthClient(user.id, 'Test', ['http://[::1]:8080/callback'], ['trips:read']); + expect(result.error).toBeUndefined(); + expect(result.client).toBeDefined(); + }); + + it('allows a private-use custom scheme (#2227)', () => { + const { user } = createUser(testDb); + const uri = 'workbuddy://workbuddy/mcp/connector%3A/oauth/callback'; + const result = createOAuthClient(user.id, 'Test', [uri], ['trips:read']); + expect(result.error).toBeUndefined(); + // The stored value must round-trip untouched: the whole authorize/token + // chain compares it byte for byte. + expect(result.client!.redirect_uris).toEqual([uri]); + }); + + it('rejects dangerous schemes that a localhost host used to smuggle through (#2227)', () => { + const { user } = createUser(testDb); + for (const uri of ['javascript://localhost/%0aalert(1)', 'blob://localhost/x', 'about://localhost/x']) { + const result = createOAuthClient(user.id, 'Test', [uri], ['trips:read']); + expect(result.status).toBe(400); + expect(result.error).toContain('Dangerous'); + } + }); + + it('rejects non-navigable schemes on a localhost host (#2227)', () => { + const { user } = createUser(testDb); + for (const uri of ['ftp://localhost/x', 'wss://localhost/x']) { + const result = createOAuthClient(user.id, 'Test', [uri], ['trips:read']); + expect(result.status).toBe(400); + expect(result.error).toContain('HTTPS'); + } + }); + it('returns 400 error if no scopes provided', () => { const { user } = createUser(testDb); const result = createOAuthClient(user.id, 'Test', ['https://example.com/cb'], []); @@ -623,6 +658,31 @@ describe('validateAuthorizeRequest', () => { expect(result.error).toBe('invalid_redirect_uri'); }); + it('accepts a loopback redirect on the port the OS handed the client (#2227)', () => { + const { user } = createUser(testDb); + const created = makeClient(user.id, { redirectUris: ['http://[::1]:8080/oauth/callback'] }); + const clientId = created.client!.client_id as string; + + const result = validateAuthorizeRequest( + makeParams({ client_id: clientId, redirect_uri: 'http://[::1]:54321/oauth/callback' }), + user.id + ); + expect(result.valid).toBe(true); + }); + + it('relaxes only the port of a loopback redirect, never the path (#2227)', () => { + const { user } = createUser(testDb); + const created = makeClient(user.id, { redirectUris: ['http://127.0.0.1:8080/oauth/callback'] }); + const clientId = created.client!.client_id as string; + + const result = validateAuthorizeRequest( + makeParams({ client_id: clientId, redirect_uri: 'http://127.0.0.1:54321/stolen' }), + user.id + ); + expect(result.valid).toBe(false); + expect(result.error).toBe('invalid_redirect_uri'); + }); + it('validates scope against client allowed_scopes', () => { const { user } = createUser(testDb); const created = makeClient(user.id, { scopes: ['trips:read'] }); diff --git a/shared/src/i18n/ar/settings.ts b/shared/src/i18n/ar/settings.ts index 3fc02634d..9c5dcc9dd 100644 --- a/shared/src/i18n/ar/settings.ts +++ b/shared/src/i18n/ar/settings.ts @@ -172,7 +172,8 @@ const settings: TranslationStrings = { 'settings.oauth.modal.clientName': 'اسم التطبيق', 'settings.oauth.modal.clientNamePlaceholder': 'مثال: Claude Web، تطبيق MCP الخاص بي', 'settings.oauth.modal.redirectUris': 'عناوين URI لإعادة التوجيه', - 'settings.oauth.modal.redirectUrisHint': 'عنوان URI واحد لكل سطر. يُطلب HTTPS (localhost مستثنى). يُطبق تطابق دقيق.', + 'settings.oauth.modal.redirectUrisHint': + 'عنوان URI واحد لكل سطر. يُسمح بـ HTTPS أو HTTP محلي أو مخطط تطبيق خاص (myapp://). يُطبق تطابق دقيق، باستثناء منفذ العنوان المحلي.', 'settings.oauth.modal.scopes': 'النطاقات المسموح بها', 'settings.oauth.modal.scopesHint': 'list_trips وget_trip_summary متاحان دائماً — لا يُطلب نطاق. يساعدان الذكاء الاصطناعي في اكتشاف معرّفات الرحلات.', diff --git a/shared/src/i18n/br/settings.ts b/shared/src/i18n/br/settings.ts index 95f7aa613..7e74d2173 100644 --- a/shared/src/i18n/br/settings.ts +++ b/shared/src/i18n/br/settings.ts @@ -247,7 +247,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'URIs de redirecionamento', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Uma URI por linha. HTTPS obrigatório (localhost isento). Correspondência exata.', + 'Uma URI por linha. HTTPS, HTTP em loopback ou um esquema de aplicativo próprio (myapp://). Correspondência exata, exceto a porta de uma URI em loopback.', 'settings.oauth.modal.scopes': 'Escopos permitidos', 'settings.oauth.modal.scopesHint': 'list_trips e get_trip_summary estão sempre disponíveis — sem escopo necessário. Permitem à IA descobrir IDs de viagem.', diff --git a/shared/src/i18n/ca/settings.ts b/shared/src/i18n/ca/settings.ts index e5dca8f5b..549f47a85 100644 --- a/shared/src/i18n/ca/settings.ts +++ b/shared/src/i18n/ca/settings.ts @@ -142,7 +142,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'URIs de redirecció', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Un URI per línia. HTTPS obligatori (localhost exempt). Coincidència exacta.', + 'Un URI per línia. HTTPS, HTTP en bucle local o un esquema propi com myapp://. Coincidència exacta, excepte el port en bucle local.', 'settings.oauth.modal.scopes': 'Àmbits permesos', 'settings.oauth.modal.scopesHint': 'list_trips i get_trip_summary sempre estan disponibles — sense àmbit requerit. Permeten a la IA descobrir els IDs de viatge necessaris.', diff --git a/shared/src/i18n/cs/settings.ts b/shared/src/i18n/cs/settings.ts index c6745f6dc..f4f059488 100644 --- a/shared/src/i18n/cs/settings.ts +++ b/shared/src/i18n/cs/settings.ts @@ -156,7 +156,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'Přesměrovací URI', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Jedno URI na řádek. Vyžadováno HTTPS (localhost vyjmuto). Vyžadována přesná shoda.', + 'Jedno URI na řádek. HTTPS, HTTP na loopbacku nebo vlastní schéma aplikace (myapp://). Přesná shoda, kromě portu u loopback URI.', 'settings.oauth.modal.scopes': 'Povolená oprávnění', 'settings.oauth.modal.scopesHint': 'list_trips a get_trip_summary jsou vždy dostupné — bez požadovaného oprávnění. Umožňují AI zjistit potřebná ID výletů.', diff --git a/shared/src/i18n/de/settings.ts b/shared/src/i18n/de/settings.ts index 14c031a88..0dbc8476f 100644 --- a/shared/src/i18n/de/settings.ts +++ b/shared/src/i18n/de/settings.ts @@ -160,7 +160,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'Redirect-URIs', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Eine URI pro Zeile. HTTPS erforderlich (localhost ausgenommen). Exakte Übereinstimmung erforderlich.', + 'Eine URI pro Zeile. HTTPS, Loopback-HTTP oder ein eigenes App-Schema (myapp://). Exakte Übereinstimmung, außer beim Port einer Loopback-URI.', 'settings.oauth.modal.scopes': 'Erlaubte Berechtigungen', 'settings.oauth.modal.scopesHint': 'list_trips und get_trip_summary sind immer verfügbar — keine Berechtigung nötig. Sie helfen der KI, Trip-IDs zu ermitteln.', diff --git a/shared/src/i18n/en/settings.ts b/shared/src/i18n/en/settings.ts index 8ab846faf..45c134186 100644 --- a/shared/src/i18n/en/settings.ts +++ b/shared/src/i18n/en/settings.ts @@ -204,7 +204,8 @@ const settings: TranslationStrings = { 'settings.oauth.modal.clientNamePlaceholder': 'e.g. Claude Web, My MCP App', 'settings.oauth.modal.redirectUris': 'Redirect URIs', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', - 'settings.oauth.modal.redirectUrisHint': 'One URI per line. HTTPS required (localhost exempt). Exact match enforced.', + 'settings.oauth.modal.redirectUrisHint': + 'One URI per line. HTTPS, loopback HTTP, or a private app scheme (myapp://). Matched exactly, except for the port of a loopback URI.', 'settings.oauth.modal.scopes': 'Allowed Scopes', 'settings.oauth.modal.scopesHint': 'list_trips and get_trip_summary are always available — no scope required. They let the AI discover trip IDs needed to use any other tool.', diff --git a/shared/src/i18n/es/settings.ts b/shared/src/i18n/es/settings.ts index 6b5e10799..9adabb68a 100644 --- a/shared/src/i18n/es/settings.ts +++ b/shared/src/i18n/es/settings.ts @@ -159,7 +159,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'URIs de redirección', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Un URI por línea. HTTPS obligatorio (localhost exento). Coincidencia exacta.', + 'Un URI por línea. HTTPS, HTTP de bucle local o un esquema propio de la aplicación (myapp://). Coincidencia exacta, excepto el puerto de bucle local.', 'settings.oauth.modal.scopes': 'Ámbitos permitidos', 'settings.oauth.modal.scopesHint': 'list_trips y get_trip_summary siempre están disponibles — sin ámbito requerido. Permiten a la IA descubrir los IDs de viaje necesarios.', diff --git a/shared/src/i18n/fr/settings.ts b/shared/src/i18n/fr/settings.ts index f9a8c8371..c011b75ba 100644 --- a/shared/src/i18n/fr/settings.ts +++ b/shared/src/i18n/fr/settings.ts @@ -161,7 +161,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'URIs de redirection', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Une URI par ligne. HTTPS requis (localhost exempté). Correspondance exacte.', + 'Une URI par ligne. HTTPS, HTTP en boucle locale ou un schéma applicatif privé (myapp://). Correspondance exacte, sauf le port en boucle locale.', 'settings.oauth.modal.scopes': 'Portées autorisées', 'settings.oauth.modal.scopesHint': "list_trips et get_trip_summary sont toujours disponibles — aucune portée requise. Ils permettent à l'IA de découvrir les IDs de voyage nécessaires.", diff --git a/shared/src/i18n/gr/settings.ts b/shared/src/i18n/gr/settings.ts index e096edef3..0600ed4be 100644 --- a/shared/src/i18n/gr/settings.ts +++ b/shared/src/i18n/gr/settings.ts @@ -204,7 +204,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'Redirect URIs', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Ένα URI ανά γραμμή. Απαιτείται HTTPS (το localhost εξαιρείται). Επιβάλλεται ακριβής αντιστοίχιση.', + 'Ένα URI ανά γραμμή. HTTPS, HTTP σε loopback ή ιδιωτικό σχήμα εφαρμογής (myapp://). Ακριβής αντιστοίχιση, εκτός από τη θύρα σε loopback URI.', 'settings.oauth.modal.scopes': 'Επιτρεπόμενα Scopes', 'settings.oauth.modal.scopesHint': 'Τα list_trips και get_trip_summary είναι πάντα διαθέσιμα — δεν απαιτείται scope. Επιτρέπουν στο AI να εντοπίζει τα trip IDs που χρειάζονται για τη χρήση οποιουδήποτε άλλου εργαλείου.', diff --git a/shared/src/i18n/hu/settings.ts b/shared/src/i18n/hu/settings.ts index e8a57cdab..fac367cb3 100644 --- a/shared/src/i18n/hu/settings.ts +++ b/shared/src/i18n/hu/settings.ts @@ -159,7 +159,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'Átirányítási URI-k', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Soronként egy URI. HTTPS szükséges (localhost kivételével). Pontos egyezés szükséges.', + 'Soronként egy URI. HTTPS, loopback HTTP vagy saját alkalmazásséma (myapp://). Pontos egyezés, kivéve a loopback URI portját.', 'settings.oauth.modal.scopes': 'Engedélyezett jogosultságok', 'settings.oauth.modal.scopesHint': 'A list_trips és get_trip_summary mindig elérhető — jogosultság nélkül. Segítenek az AI-nak megtalálni az utazás azonosítókat.', diff --git a/shared/src/i18n/id/settings.ts b/shared/src/i18n/id/settings.ts index 7bc1b941c..d8252e027 100644 --- a/shared/src/i18n/id/settings.ts +++ b/shared/src/i18n/id/settings.ts @@ -192,7 +192,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'Redirect URI', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://aplikasiku.com/callback\nhttps://aplikasiku.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Satu URI per baris. HTTPS wajib (localhost dikecualikan). Kecocokan tepat diberlakukan.', + 'Satu URI per baris. HTTPS, HTTP loopback, atau skema aplikasi pribadi (myapp://). Kecocokan tepat, kecuali port pada URI loopback.', 'settings.oauth.modal.scopes': 'Cakupan yang Diizinkan', 'settings.oauth.modal.scopesHint': 'list_trips dan get_trip_summary selalu tersedia — tidak perlu cakupan. Keduanya memungkinkan AI menemukan ID perjalanan yang diperlukan untuk menggunakan alat lainnya.', diff --git a/shared/src/i18n/it/settings.ts b/shared/src/i18n/it/settings.ts index f315b7b83..3e416c1ad 100644 --- a/shared/src/i18n/it/settings.ts +++ b/shared/src/i18n/it/settings.ts @@ -159,7 +159,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'URI di reindirizzamento', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Un URI per riga. HTTPS richiesto (localhost esente). Corrispondenza esatta richiesta.', + 'Un URI per riga. HTTPS, HTTP di loopback o uno schema privato come myapp://. Corrispondenza esatta, tranne la porta di loopback.', 'settings.oauth.modal.scopes': 'Ambiti consentiti', 'settings.oauth.modal.scopesHint': "list_trips e get_trip_summary sono sempre disponibili — nessun ambito richiesto. Permettono all'IA di scoprire gli ID viaggio necessari.", diff --git a/shared/src/i18n/ja/settings.ts b/shared/src/i18n/ja/settings.ts index 22ba43862..23659577d 100644 --- a/shared/src/i18n/ja/settings.ts +++ b/shared/src/i18n/ja/settings.ts @@ -181,7 +181,8 @@ const settings: TranslationStrings = { 'settings.oauth.modal.clientNamePlaceholder': '例:Claude Web', 'settings.oauth.modal.redirectUris': 'リダイレクトURI', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback', - 'settings.oauth.modal.redirectUrisHint': '1行につき1つ。HTTPS必須。', + 'settings.oauth.modal.redirectUrisHint': + '1行につき1つ。HTTPS、ループバックHTTP、または独自のアプリスキーム(myapp://)が使えます。ループバックURIのポート番号を除き、完全一致が必要です。', 'settings.oauth.modal.scopes': '許可スコープ', 'settings.oauth.modal.scopesHint': 'list_trips と get_trip_summary は常に利用可能です。', 'settings.oauth.modal.selectAll': 'すべて選択', diff --git a/shared/src/i18n/ko/settings.ts b/shared/src/i18n/ko/settings.ts index 712aab462..52335ca6f 100644 --- a/shared/src/i18n/ko/settings.ts +++ b/shared/src/i18n/ko/settings.ts @@ -192,7 +192,8 @@ const settings: TranslationStrings = { 'settings.oauth.modal.clientNamePlaceholder': '예: Claude Web, My MCP App', 'settings.oauth.modal.redirectUris': '리디렉션 URI', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', - 'settings.oauth.modal.redirectUrisHint': '한 줄에 URI 하나. HTTPS 필수 (localhost 예외). 정확히 일치해야 합니다.', + 'settings.oauth.modal.redirectUrisHint': + '한 줄에 URI 하나. HTTPS, 루프백 HTTP 또는 앱 전용 스킴(myapp://)을 쓸 수 있습니다. 루프백 URI의 포트를 빼고 정확히 일치해야 합니다.', 'settings.oauth.modal.scopes': '허용 권한 범위', 'settings.oauth.modal.scopesHint': 'list_trips 및 get_trip_summary는 항상 사용 가능합니다 — 권한 범위 불필요. AI가 다른 도구를 사용하는 데 필요한 여행 ID를 찾을 수 있습니다.', diff --git a/shared/src/i18n/nl/settings.ts b/shared/src/i18n/nl/settings.ts index 5cb97fd4e..99339c02d 100644 --- a/shared/src/i18n/nl/settings.ts +++ b/shared/src/i18n/nl/settings.ts @@ -158,7 +158,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': "Redirect-URI's", 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Eén URI per regel. HTTPS vereist (localhost uitgezonderd). Exacte overeenkomst vereist.', + 'Eén URI per regel. HTTPS, loopback-HTTP of een eigen app-schema (myapp://). Exacte overeenkomst, behalve de poort van een loopback-URI.', 'settings.oauth.modal.scopes': 'Toegestane rechten', 'settings.oauth.modal.scopesHint': "list_trips en get_trip_summary zijn altijd beschikbaar — geen recht vereist. Ze helpen de AI trip-ID's te ontdekken.", diff --git a/shared/src/i18n/pl/settings.ts b/shared/src/i18n/pl/settings.ts index 6975221db..4e2590a9b 100644 --- a/shared/src/i18n/pl/settings.ts +++ b/shared/src/i18n/pl/settings.ts @@ -154,7 +154,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'URI przekierowania', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Jeden URI na linię. Wymagane HTTPS (localhost zwolniony). Wymagana dokładna zgodność.', + 'Jeden URI na linię. HTTPS, HTTP na loopbacku lub własny schemat aplikacji (myapp://). Dokładna zgodność, z wyjątkiem portu w URI loopback.', 'settings.oauth.modal.scopes': 'Dozwolone uprawnienia', 'settings.oauth.modal.scopesHint': 'list_trips i get_trip_summary są zawsze dostępne — bez wymaganych uprawnień. Umożliwiają AI odkrycie potrzebnych ID podróży.', diff --git a/shared/src/i18n/ru/settings.ts b/shared/src/i18n/ru/settings.ts index b39cd4338..cafd1b32b 100644 --- a/shared/src/i18n/ru/settings.ts +++ b/shared/src/i18n/ru/settings.ts @@ -157,7 +157,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'URI перенаправления', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Один URI на строку. Требуется HTTPS (localhost исключён). Требуется точное совпадение.', + 'Один URI на строку. HTTPS, локальный HTTP или собственная схема приложения (myapp://). Требуется точное совпадение, кроме порта локального URI.', 'settings.oauth.modal.scopes': 'Разрешённые области доступа', 'settings.oauth.modal.scopesHint': 'list_trips и get_trip_summary всегда доступны — область не требуется. Они помогают ИИ находить нужные ID поездок.', diff --git a/shared/src/i18n/sv/settings.ts b/shared/src/i18n/sv/settings.ts index f442dafb6..11117e096 100644 --- a/shared/src/i18n/sv/settings.ts +++ b/shared/src/i18n/sv/settings.ts @@ -200,7 +200,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'Omdirigerings-URI:er', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://din-app.com/callback\nhttps://din-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'En URI per rad. HTTPS krävs (med undantag för localhost). Exakt matchning krävs.', + 'En URI per rad. HTTPS, loopback-HTTP eller ett eget app-schema (myapp://). Exakt matchning, utom porten i en loopback-URI.', 'settings.oauth.modal.scopes': 'Tillåtna tillämpningsområden', 'settings.oauth.modal.scopesHint': 'list_trips och get_trip_summary är alltid tillgängliga – inget tillämpningsområden krävs. De gör det möjligt för AI:n att hämta de res-ID:n som behövs för att använda andra verktyg.', diff --git a/shared/src/i18n/tr/settings.ts b/shared/src/i18n/tr/settings.ts index fc01041b0..729a05bd6 100644 --- a/shared/src/i18n/tr/settings.ts +++ b/shared/src/i18n/tr/settings.ts @@ -195,7 +195,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': "URI'leri Yönlendir", 'settings.oauth.modal.redirectUrisPlaceholder': 'https://uygulamaniz.com/callback\\nhttps://uygulamaniz.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Satır başına bir URI. HTTPS gerekli (yerel ana bilgisayar hariç). Tam eşleşme uygulandı.', + 'Satır başına bir URI. HTTPS, geri döngü HTTP veya kendi uygulama şeması (myapp://). Geri döngü URI portu dışında tam eşleşme gerekir.', 'settings.oauth.modal.scopes': 'İzin Verilen Kapsamlar', 'settings.oauth.modal.scopesHint': 'list_trips ve get_trip_summary her zaman kullanılabilir; kapsam gerektirmez. Yapay zekanın başka herhangi bir aracı kullanmak için gereken yolculuk kimliklerini keşfetmesine olanak tanıyorlar.', diff --git a/shared/src/i18n/uk/settings.ts b/shared/src/i18n/uk/settings.ts index 3e7f923bb..9a3fc941f 100644 --- a/shared/src/i18n/uk/settings.ts +++ b/shared/src/i18n/uk/settings.ts @@ -159,7 +159,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'URI перенаправлення', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Один URI на рядок. Потрібен HTTPS (localhost виключено). Потрібне точне співпадіння.', + 'Один URI на рядок. HTTPS, локальний HTTP або власна схема застосунку (myapp://). Потрібне точне співпадіння, окрім порту локального URI.', 'settings.oauth.modal.scopes': 'Дозволені області доступу', 'settings.oauth.modal.scopesHint': 'list_trips і get_trip_summary завжди доступні — область не потрібна. Вони допомагають ШІ знаходити потрібні ID поїздок.', diff --git a/shared/src/i18n/vi/settings.ts b/shared/src/i18n/vi/settings.ts index 7b84a2cea..cb0d69b42 100644 --- a/shared/src/i18n/vi/settings.ts +++ b/shared/src/i18n/vi/settings.ts @@ -201,7 +201,7 @@ const settings: TranslationStrings = { 'settings.oauth.modal.redirectUris': 'URI chuyển hướng', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', 'settings.oauth.modal.redirectUrisHint': - 'Một URI trên mỗi dòng. Yêu cầu HTTPS (miễn trừ localhost). Đối sánh chính xác được thực thi.', + 'Một URI trên mỗi dòng. HTTPS, HTTP loopback hoặc lược đồ ứng dụng riêng (myapp://). Đối sánh chính xác, ngoại trừ cổng của URI loopback.', 'settings.oauth.modal.scopes': 'Phạm vi được phép', 'settings.oauth.modal.scopesHint': 'list_trips và get_trip_summary luôn có sẵn — không yêu cầu phạm vi. Họ cho phép AI khám phá ID chuyến đi cần thiết để sử dụng bất kỳ công cụ nào khác.', diff --git a/shared/src/i18n/zh-TW/settings.ts b/shared/src/i18n/zh-TW/settings.ts index 493666d42..c584c8d9b 100644 --- a/shared/src/i18n/zh-TW/settings.ts +++ b/shared/src/i18n/zh-TW/settings.ts @@ -182,7 +182,8 @@ const settings: TranslationStrings = { 'settings.oauth.modal.clientNamePlaceholder': '例如 Claude Web、我的 MCP 應用程式', 'settings.oauth.modal.redirectUris': '重新導向 URI', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', - 'settings.oauth.modal.redirectUrisHint': '每行一個 URI。需要 HTTPS(localhost 除外)。需要完全符合。', + 'settings.oauth.modal.redirectUrisHint': + '每行一個 URI。支援 HTTPS、回送 HTTP 或自訂應用程式協定(myapp://)。需要完全符合,回送 URI 的連接埠除外。', 'settings.oauth.modal.scopes': '允許的授權範圍', 'settings.oauth.modal.scopesHint': 'list_trips 和 get_trip_summary 始終可用——不需要授權範圍。它們可幫助 AI 找到所需的行程 ID。', diff --git a/shared/src/i18n/zh/settings.ts b/shared/src/i18n/zh/settings.ts index 8bbeb4c2e..aee92e589 100644 --- a/shared/src/i18n/zh/settings.ts +++ b/shared/src/i18n/zh/settings.ts @@ -147,7 +147,8 @@ const settings: TranslationStrings = { 'settings.oauth.modal.clientNamePlaceholder': '例如 Claude Web、我的 MCP 应用', 'settings.oauth.modal.redirectUris': '重定向 URI', 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', - 'settings.oauth.modal.redirectUrisHint': '每行一个 URI。需要 HTTPS(localhost 除外)。要求精确匹配。', + 'settings.oauth.modal.redirectUrisHint': + '每行一个 URI。支持 HTTPS、回环 HTTP 或自定义应用协议(myapp://)。要求精确匹配,回环 URI 的端口除外。', 'settings.oauth.modal.scopes': '允许的权限范围', 'settings.oauth.modal.scopesHint': 'list_trips 和 get_trip_summary 始终可用——无需权限范围。它们帮助 AI 发现所需的行程 ID。',