Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/magic-link-post.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@seamless-auth/react': minor
---

Request magic links over `POST /auth/magic-link` instead of `GET`.

`GET /auth/magic-link` was a state-changing route reachable as a simple cross-site request, so an `<img src>` on any page could trigger unbounded magic-link emails to a signed-in user. The Express adapter removed the GET route and replaced it with POST.

The request now carries an empty JSON body. The adapter ignores it, but the resulting JSON content type forces a CORS preflight, which is what actually keeps the route from being reachable cross-site. A bodyless POST would still be a simple request.

BREAKING: this release requires an adapter with the POST route (`@seamless-auth/express` 0.9.0 or later). Against an older adapter, `requestMagicLink` gets a 404. Callers of `requestMagicLink()` need no code change.
5 changes: 4 additions & 1 deletion src/client/createSeamlessAuthClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,9 +538,12 @@ export const createSeamlessAuthClient = (
'Failed to verify the email code.'
),

// Sends an empty JSON body on purpose. The adapter ignores it, but it makes
// fetchWithAuth declare a JSON content type, which forces a CORS preflight.
// A bodyless POST is still a simple request and stays reachable cross-site.
requestMagicLink: () =>
requestResult<MessageResult>(
fetchWithAuth(`/magic-link`, { method: 'GET' }),
fetchWithAuth(`/magic-link`, { method: 'POST', body: JSON.stringify({}) }),
'Failed to send the magic link.'
),

Expand Down
23 changes: 22 additions & 1 deletion tests/createSeamlessAuthClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,26 @@ describe('createSeamlessAuthClient', () => {
});
});

// A GET here was CSRF-able: an img tag on any page triggered magic-link
// emails to the victim. The JSON body is what forces the preflight.
it('requests a magic link over POST with a content type that forces a preflight', async () => {
mockFetchWithAuth.mockResolvedValue({
ok: true,
json: async () => ({ message: 'Success' }),
});

const client = createSeamlessAuthClient({
apiHost: 'https://api.example.com',
});

expect((await client.requestMagicLink()).error).toBeNull();

expect(mockFetchWithAuth).toHaveBeenCalledWith('/magic-link', {
method: 'POST',
body: JSON.stringify({}),
});
});

it('keeps an untrusted magic-link token inside its own path segment', async () => {
mockFetchWithAuth.mockResolvedValue({
ok: true,
Expand Down Expand Up @@ -263,7 +283,8 @@ describe('createSeamlessAuthClient', () => {
method: 'DELETE',
});
expect(mockFetchWithAuth).toHaveBeenNthCalledWith(2, '/magic-link', {
method: 'GET',
method: 'POST',
body: JSON.stringify({}),
});
});

Expand Down
Loading