You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Adds redirect loop detection to prevent users from creating circular redirect chains (e.g., /one → /two → /three → /one) that cause browsers to show ERR_TOO_MANY_REDIRECTS.
When creating or updating a redirect, we build a directed graph from all enabled redirects and walk the chain from the proposed destination. If the walk reaches the proposed source, we reject with a validation error showing the full loop path.
For new redirects: The create dialog blocks saving and shows the loop chain path (one hop per line) as an error message.
For edits: If the updated source or destination would create a loop, the save is blocked with the same error.
How it works
Step 1: Graph construction: On every create/update/delete, we load all enabled redirects from the database build a directed graph, where each source path points to its destination.
Step 2: Walk algorithm: Starting from the proposed redirect's destination, we follow the graph one hop at a time. At each hop, we resolve the next destination by:
Exact match: look up the path directly in the graph
Pattern match: if no exact match, test the path against pattern redirects like /blog/[slug] and /blog/[...rest] (e.g., /blog/hello matches /blog/[slug])
If the walk reaches the proposed source, we reject with the full loop chain as an error.
Step 3: The loop analysis result (just the IDs of redirects participating in loops) is stored in the options table as an array, under the key _redirect_loop_ids. Empty array means no loops. This is read on every admin page load. No graph computation on reads.
What about existing redirects?
Users who already have redirect loops set up before this change are not blocked. Their redirects continue to work as before. Instead:
On first admin page load after the loop detection change is deployed, the loop analysis is computed and stored in the options table.
The admin redirects page shows a warning banner ("Redirect loop detected") with the count of affected redirects
Each redirect participating in a loop shows a warning icon on its row
The enable/disable toggle is not restricted. Users can freely toggle existing redirects. The loop check only runs when source or destination is changed, or when creating a new redirect.
Performance
To test the loop detection performance, I referred to the "Waiting for server response" time via the browser DevTools Network → Timing tab.
Performance of the /redirects (POST) endpoint with 1 existing redirects in the database:
Operation
Duration
Create with loop check (no loop)
~6.59
Create with loop rejection
~5.89
Performance of the /redirects (POST) endpoint with 1000 existing redirects in the database:
Operation
Duration
Create with loop check (no loop)
~10.9ms
Create with loop rejection
~10.79ms
This includes all middleware overhead (auth, CSRF, session handling, request parsing), and not just the loop detection.
The actual loop detection (graph build + walk) takes ~2.5ms for 1000 redirects, and can be verified using this script:
Performance test script
/**
* Performance test: create 1000 non-looping redirects, then time the
* creation of the 1001st (which triggers loop detection against all 1000).
*
* Usage: node scripts/perf-test-redirects.mjs
*
* Requires the dev server running on localhost:4321
*/
const BASE = "http://localhost:4321";
const API = `${BASE}/_emdash/api/redirects`;
async function getSessionCookie() {
const res = await fetch(`${BASE}/_emdash/api/setup/dev-bypass`);
const cookie = res.headers.get("set-cookie");
return cookie?.split(";")[0] ?? "";
}
async function run() {
const cookie = await getSessionCookie();
const headers = {
"Content-Type": "application/json",
"X-EmDash-Request": "1",
Origin: BASE,
Cookie: cookie,
};
// Clean up existing redirects
console.log("Cleaning up existing redirects...");
let hasMore = true;
while (hasMore) {
const listRes = await fetch(`${API}?limit=100`, { headers });
const listData = await listRes.json();
const items = listData.data?.items ?? [];
if (items.length === 0) break;
for (const item of items) {
await fetch(`${API}/${item.id}`, { method: "DELETE", headers });
}
hasMore = !!listData.data?.nextCursor;
}
// Create 1000 non-looping redirects
console.log("Creating 1000 redirects...");
const total = 1000;
const createStart = performance.now();
for (let i = 0; i < total; i++) {
const res = await fetch(API, {
method: "POST",
headers,
body: JSON.stringify({
source: `/perf-${i}`,
destination: `/dest-${i}`,
}),
});
if (!res.ok) {
const body = await res.text();
console.error(`Failed at ${i}: ${res.status} ${body}`);
break;
}
if ((i + 1) % 100 === 0) {
console.log(` Created ${i + 1}/${total}`);
}
}
const createDuration = performance.now() - createStart;
console.log(
`\nCreated ${total} redirects in ${createDuration.toFixed(0)}ms (${(createDuration / total).toFixed(1)}ms per redirect)`,
);
// Time the 1001st redirect (triggers loop check against all 1000)
console.log("\n--- Loop check with 1000 existing redirects ---");
const checkStart = performance.now();
const res = await fetch(API, {
method: "POST",
headers,
body: JSON.stringify({
source: "/perf-final",
destination: "/dest-final",
}),
});
const checkDuration = performance.now() - checkStart;
console.log(`Status: ${res.status} (${res.ok ? "created" : "rejected"})`);
console.log(`Duration: ${checkDuration.toFixed(1)}ms`);
// Create a chain and attempt to close the loop
console.log("\n--- Loop rejection with 1001 existing redirects ---");
await fetch(API, {
method: "POST",
headers,
body: JSON.stringify({ source: "/chain-a", destination: "/chain-b" }),
});
await fetch(API, {
method: "POST",
headers,
body: JSON.stringify({ source: "/chain-b", destination: "/chain-c" }),
});
const loopStart = performance.now();
const loopRes = await fetch(API, {
method: "POST",
headers,
body: JSON.stringify({ source: "/chain-c", destination: "/chain-a" }),
});
const loopDuration = performance.now() - loopStart;
const loopBody = await loopRes.json();
console.log(`Status: ${loopRes.status} (${loopRes.ok ? "created" : "rejected"})`);
console.log(`Duration: ${loopDuration.toFixed(1)}ms`);
if (!loopRes.ok) {
console.log(`Error: ${loopBody.error?.message}`);
}
// Time the list endpoint (reads cached loop IDs)
console.log("\n--- List endpoint with 1003 redirects ---");
const listStart = performance.now();
const listRes2 = await fetch(API, { headers });
const listDuration = performance.now() - listStart;
console.log(`Status: ${listRes2.status}`);
console.log(`Duration: ${listDuration.toFixed(1)}ms`);
// Cleanup
console.log("\nCleaning up...");
let deleted = 0;
hasMore = true;
while (hasMore) {
const cleanRes = await fetch(`${API}?limit=100`, { headers });
const cleanData = await cleanRes.json();
const items = cleanData.data?.items ?? [];
if (items.length === 0) break;
for (const item of items) {
await fetch(`${API}/${item.id}`, { method: "DELETE", headers });
deleted++;
}
hasMore = !!cleanData.data?.nextCursor;
}
console.log(`Deleted ${deleted} redirects`);
}
run().catch(console.error);
Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.
This PR includes no changesets
When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Adds redirect loop detection to prevent users from creating circular redirect chains (e.g., /one → /two → /three → /one) that cause browsers to show ERR_TOO_MANY_REDIRECTS.
Closes #350
When creating or updating a redirect, we build a directed graph from all enabled redirects and walk the chain from the proposed destination. If the walk reaches the proposed source, we reject with a validation error showing the full loop path.
For new redirects: The create dialog blocks saving and shows the loop chain path (one hop per line) as an error message.
For edits: If the updated source or destination would create a loop, the save is blocked with the same error.
How it works
Step 1: Graph construction: On every create/update/delete, we load all enabled redirects from the database build a directed graph, where each source path points to its destination.
Step 2: Walk algorithm: Starting from the proposed redirect's destination, we follow the graph one hop at a time. At each hop, we resolve the next destination by:
Step 3: The loop analysis result (just the IDs of redirects participating in loops) is stored in the
optionstable as an array, under the key_redirect_loop_ids. Empty array means no loops. This is read on every admin page load. No graph computation on reads.What about existing redirects?
Users who already have redirect loops set up before this change are not blocked. Their redirects continue to work as before. Instead:
Performance
To test the loop detection performance, I referred to the "Waiting for server response" time via the browser DevTools Network → Timing tab.
Performance of the /redirects (POST) endpoint with 1 existing redirects in the database:
Performance of the /redirects (POST) endpoint with 1000 existing redirects in the database:
This includes all middleware overhead (auth, CSRF, session handling, request parsing), and not just the loop detection.
The actual loop detection (graph build + walk) takes ~2.5ms for 1000 redirects, and can be verified using this script:
Performance test script
Type of change
Checklist
pnpm typecheckpassespnpm --silent lint:json | jq '.diagnostics | length'returns 0pnpm testpasses (or targeted tests for my change)pnpm formathas been runAI-generated code disclosure
Screenshots / test output