diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index 251063621967..d662a0f71845 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -995,6 +995,20 @@ export const RedirectWithNoLocation = { name: 'RedirectWithNoLocation', title: 'A redirect must be given a location with the `Location` header.', } satisfies ErrorData; + +/** + * @docs + * @see + * - [Astro.redirect](https://docs.astro.build/en/reference/api-reference/#redirect) + * @description + * An external redirect must start with http or https, and must be a valid URL + */ +export const UnsupportedExternalRedirect = { + name: 'UnsupportedExternalRedirect', + title: 'Unsupported or malformed URL.', + message: 'An external redirect must start with http or https, and must be a valid URL', +} satisfies ErrorData; + /** * @docs * @see diff --git a/packages/astro/src/core/redirects/render.ts b/packages/astro/src/core/redirects/render.ts index 379f26e3ba48..6245db9e0fd5 100644 --- a/packages/astro/src/core/redirects/render.ts +++ b/packages/astro/src/core/redirects/render.ts @@ -1,5 +1,14 @@ +import type { RedirectConfig } from '../../types/public/index.js'; import type { RenderContext } from '../render-context.js'; +export function redirectIsExternal(redirect: RedirectConfig): boolean { + if (typeof redirect === 'string') { + return redirect.startsWith('http') || redirect.startsWith('https'); + } else { + return redirect.destination.startsWith('http') || redirect.destination.startsWith('https'); + } +} + export async function renderRedirect(renderContext: RenderContext) { const { request: { method }, @@ -9,6 +18,13 @@ export async function renderRedirect(renderContext: RenderContext) { const status = redirectRoute && typeof redirect === 'object' ? redirect.status : method === 'GET' ? 301 : 308; const headers = { location: encodeURI(redirectRouteGenerate(renderContext)) }; + if (redirect && redirectIsExternal(redirect)) { + if (typeof redirect === 'string') { + return Response.redirect(redirect, status); + } else { + return Response.redirect(redirect.destination, status); + } + } return new Response(null, { status, headers }); } @@ -21,13 +37,17 @@ function redirectRouteGenerate(renderContext: RenderContext): string { if (typeof redirectRoute !== 'undefined') { return redirectRoute?.generate(params) || redirectRoute?.pathname || '/'; } else if (typeof redirect === 'string') { - // TODO: this logic is duplicated between here and manifest/create.ts - let target = redirect; - for (const param of Object.keys(params)) { - const paramValue = params[param]!; - target = target.replace(`[${param}]`, paramValue).replace(`[...${param}]`, paramValue); + if (redirectIsExternal(redirect)) { + return redirect; + } else { + // TODO: this logic is duplicated between here and manifest/create.ts + let target = redirect; + for (const param of Object.keys(params)) { + const paramValue = params[param]!; + target = target.replace(`[${param}]`, paramValue).replace(`[...${param}]`, paramValue); + } + return target; } - return target; } else if (typeof redirect === 'undefined') { return '/'; } diff --git a/packages/astro/src/core/routing/manifest/create.ts b/packages/astro/src/core/routing/manifest/create.ts index b0495a682ad8..73c4229c25d6 100644 --- a/packages/astro/src/core/routing/manifest/create.ts +++ b/packages/astro/src/core/routing/manifest/create.ts @@ -14,7 +14,10 @@ import { getPrerenderDefault } from '../../../prerender/utils.js'; import type { AstroConfig } from '../../../types/public/config.js'; import type { RouteData, RoutePart } from '../../../types/public/internal.js'; import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from '../../constants.js'; -import { MissingIndexForInternationalization } from '../../errors/errors-data.js'; +import { + MissingIndexForInternationalization, + UnsupportedExternalRedirect, +} from '../../errors/errors-data.js'; import { AstroError } from '../../errors/index.js'; import { removeLeadingForwardSlash, slash } from '../../path.js'; import { injectServerIslandRoute } from '../../server-islands/endpoint.js'; @@ -348,11 +351,13 @@ function createRedirectRoutes( destination = to.destination; } - if (/^https?:\/\//.test(destination)) { - logger.warn( - 'redirects', - `Redirecting to an external URL is not officially supported: ${from} -> ${destination}`, - ); + // URLs that don't start with leading slash should be considered external + if (!destination.startsWith('/')) { + // check if the link starts with http or https; if not, log a warning + if (!/^https?:\/\//.test(destination) && !URL.canParse(destination)) { + logger.warn('redirects', 'Only links that start with http or https are supported.'); + throw new AstroError(UnsupportedExternalRedirect); + } } routes.push({ diff --git a/packages/astro/test/redirects.test.js b/packages/astro/test/redirects.test.js index 8014fb73bb45..7cbc673e6d73 100644 --- a/packages/astro/test/redirects.test.js +++ b/packages/astro/test/redirects.test.js @@ -36,13 +36,12 @@ describe('Astro.redirect', () => { assert.equal(response.headers.get('location'), '/login'); }); - // ref: https://github.com/withastro/astro/pull/9287#discussion_r1420739810 - it.skip('Ignores external redirect', async () => { + it('Allows external redirect', async () => { const app = await fixture.loadTestAdapterApp(); const request = new Request('http://example.com/external/redirect'); const response = await app.render(request); - assert.equal(response.status, 404); - assert.equal(response.headers.get('location'), null); + assert.equal(response.status, 301); + assert.equal(response.headers.get('location'), 'https://example.com/'); }); it('Warns when used inside a component', async () => { @@ -131,6 +130,7 @@ describe('Astro.redirect', () => { '/more/old/[dynamic]': '/more/[dynamic]', '/more/old/[dynamic]/[route]': '/more/[dynamic]/[route]', '/more/old/[...spread]': '/more/new/[...spread]', + '/external/redirect': 'https://example.com/', }, }); await fixture.build(); @@ -208,6 +208,12 @@ describe('Astro.redirect', () => { assert.equal(html.includes('http-equiv="refresh'), true); assert.equal(html.includes('url=/more/new/welcome/world'), true); }); + + it('supports redirecting to an external redirects', async () => { + const html = await fixture.readFile('/external/redirect/index.html'); + assert.equal(html.includes('http-equiv="refresh'), true); + assert.equal(html.includes('url=https://example.com/'), true); + }); }); describe('dev', () => {