fix: audit Cloud Backup OAuth redirects (#1324)
* fix(cloud-backup): pin OAuth callback origin * fix: reject non-web cloud backup origins
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { resolveCallbackOrigin } from '../callback-origin'
|
||||
|
||||
const originalAppUrl = process.env.NEXT_PUBLIC_APP_URL
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.NEXT_PUBLIC_APP_URL
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalAppUrl === undefined) delete process.env.NEXT_PUBLIC_APP_URL
|
||||
else process.env.NEXT_PUBLIC_APP_URL = originalAppUrl
|
||||
})
|
||||
|
||||
describe('resolveCallbackOrigin', () => {
|
||||
it('falls back to the request origin when no canonical URL is configured', () => {
|
||||
expect(resolveCallbackOrigin('https://self-hosted.example')).toBe(
|
||||
'https://self-hosted.example'
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers the canonical app origin over the request origin', () => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'https://app.accounted.se'
|
||||
expect(resolveCallbackOrigin('https://app.gnubok.se')).toBe(
|
||||
'https://app.accounted.se'
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes a trailing slash so the redirect URI stays byte-identical', () => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'https://app.accounted.se/'
|
||||
expect(resolveCallbackOrigin('https://other.example')).toBe(
|
||||
'https://app.accounted.se'
|
||||
)
|
||||
})
|
||||
|
||||
it('strips any path from the configured URL', () => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'https://app.accounted.se/dashboard'
|
||||
expect(resolveCallbackOrigin('https://other.example')).toBe(
|
||||
'https://app.accounted.se'
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores a blank value', () => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = ' '
|
||||
expect(resolveCallbackOrigin('https://other.example')).toBe(
|
||||
'https://other.example'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the request origin when the configured URL is malformed', () => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'not a url'
|
||||
expect(resolveCallbackOrigin('https://other.example')).toBe(
|
||||
'https://other.example'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the request origin for a non-web URL scheme', () => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'mailto:backup@example.com'
|
||||
expect(resolveCallbackOrigin('https://other.example')).toBe(
|
||||
'https://other.example'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Resolve the origin used to build OAuth redirect URIs.
|
||||
*
|
||||
* Both OAuth legs must send the same redirect_uri. Pinning it to the
|
||||
* deployment's canonical app URL also prevents an old domain alias or preview
|
||||
* host from generating a callback that is not registered with the provider.
|
||||
* Self-hosted deployments without NEXT_PUBLIC_APP_URL fall back to the
|
||||
* request origin.
|
||||
*/
|
||||
export function resolveCallbackOrigin(requestOrigin: string): string {
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL
|
||||
if (appUrl && appUrl.trim().length > 0) {
|
||||
try {
|
||||
// Normalizes trailing slashes and strips paths so the provider receives
|
||||
// the same bare origin on the authorization and token-exchange legs.
|
||||
const configuredUrl = new URL(appUrl)
|
||||
if (
|
||||
configuredUrl.protocol !== 'http:' &&
|
||||
configuredUrl.protocol !== 'https:'
|
||||
) {
|
||||
return requestOrigin
|
||||
}
|
||||
return configuredUrl.origin
|
||||
} catch {
|
||||
return requestOrigin
|
||||
}
|
||||
}
|
||||
return requestOrigin
|
||||
}
|
||||
Reference in New Issue
Block a user