@shipfox/client-auth 6.0.2 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/client-auth",
3
3
  "license": "MIT",
4
- "version": "6.0.2",
4
+ "version": "7.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -23,6 +23,10 @@
23
23
  "types": "./dist/continuation.d.ts",
24
24
  "default": "./dist/continuation.js"
25
25
  },
26
+ "./redirect-context": {
27
+ "types": "./dist/redirect-context.d.ts",
28
+ "default": "./dist/redirect-context.js"
29
+ },
26
30
  "./routes/*": {
27
31
  "types": "./dist/routes/*.d.ts",
28
32
  "default": "./dist/routes/*.js"
@@ -32,10 +36,10 @@
32
36
  "@swc/helpers": "^0.5.17",
33
37
  "@tanstack/react-form": "^1.32.0",
34
38
  "@shipfox/api-auth-dto": "9.0.2",
39
+ "@shipfox/api-workspaces-dto": "9.0.2",
35
40
  "@shipfox/client-api": "6.0.1",
36
41
  "@shipfox/client-invitations": "6.0.2",
37
42
  "@shipfox/client-shell": "6.0.2",
38
- "@shipfox/api-workspaces-dto": "9.0.2",
39
43
  "@shipfox/client-ui": "6.0.2",
40
44
  "@shipfox/react-ui": "0.3.7"
41
45
  },
@@ -1,5 +1,13 @@
1
1
  import {sanitizeLogoutRedirectPath, sanitizeRedirectPath} from './redirect-target.js';
2
2
 
3
+ function encodeRepeatedly(value: string, times: number): string {
4
+ let encoded = value;
5
+ for (let iteration = 0; iteration < times; iteration += 1) {
6
+ encoded = encodeURIComponent(encoded);
7
+ }
8
+ return encoded;
9
+ }
10
+
3
11
  describe('sanitizeRedirectPath', () => {
4
12
  describe.each([
5
13
  ['simple absolute path', '/foo'],
@@ -41,8 +49,12 @@ describe('sanitizeRedirectPath', () => {
41
49
  });
42
50
 
43
51
  describe('decode-then-check defenses', () => {
44
- test('rejects percent-encoded /auth/* path', () => {
45
- const result = sanitizeRedirectPath('/%61uth/login');
52
+ test.each([
53
+ ['single-encoded', '/%61uth/login'],
54
+ ['double-encoded', '/%2561uth/login'],
55
+ ['triple-encoded', '/%252561uth/login'],
56
+ ])('rejects %s /auth/* path', (_label, input) => {
57
+ const result = sanitizeRedirectPath(input);
46
58
 
47
59
  expect(result).toBeUndefined();
48
60
  });
@@ -64,6 +76,30 @@ describe('sanitizeRedirectPath', () => {
64
76
 
65
77
  expect(result).toBeUndefined();
66
78
  });
79
+
80
+ test('fails closed when the path exceeds the decode iteration cap', () => {
81
+ const deeplyEncodedAuthPath = `/${encodeRepeatedly('%61', 10)}uth/login`;
82
+
83
+ expect(sanitizeRedirectPath(deeplyEncodedAuthPath)).toBeUndefined();
84
+ });
85
+
86
+ test('rejects malformed percent-encoding that only surfaces after the first decode', () => {
87
+ const result = sanitizeRedirectPath('/%25E0%2580%2580');
88
+
89
+ expect(result).toBeUndefined();
90
+ });
91
+
92
+ test('rejects an /auth/* path disguised behind encoded dot segments', () => {
93
+ const result = sanitizeRedirectPath('/safe/%25252e%25252e/auth/login');
94
+
95
+ expect(result).toBeUndefined();
96
+ });
97
+
98
+ test('rejects a protocol-relative URL revealed only after multiple decodes', () => {
99
+ const result = sanitizeRedirectPath('/%25252fevil.com');
100
+
101
+ expect(result).toBeUndefined();
102
+ });
67
103
  });
68
104
  });
69
105
 
@@ -90,7 +126,16 @@ describe('sanitizeLogoutRedirectPath', () => {
90
126
  ['login route with a query', '/auth/login?redirect=/workspaces/abc'],
91
127
  ['raw invitation token', '/invitations/accept?token=sf_i_raw-token'],
92
128
  ['raw invitation token with trailing slash', '/invitations/accept/?token=sf_i_raw-token'],
129
+ ['double-encoded auth route', '/%2561uth/login'],
130
+ ['triple-encoded auth route', '/%252561uth/login'],
131
+ ['double-encoded invitation token', '/%2569nvitations/accept?token=sf_i_raw-token'],
132
+ ['triple-encoded invitation token', '/%252569nvitations/accept?token=sf_i_raw-token'],
93
133
  ['malformed percent encoding', '/%E0%80%80'],
134
+ [
135
+ 'invitation token disguised behind encoded dot segments',
136
+ '/safe/%25252e%25252e/invitations/accept?token=sf_i_raw-token',
137
+ ],
138
+ ['auth route disguised behind encoded dot segments', '/safe/%25252e%25252e/auth/login'],
94
139
  ])('falls back for %s', (_label, input) => {
95
140
  test('returns login without forwarding unsafe state', () => {
96
141
  expect(sanitizeLogoutRedirectPath(input)).toBe('/auth/login');
@@ -3,8 +3,38 @@ const LOGIN_PATH = '/auth/login';
3
3
  const INVITATION_ACCEPT_PATH = '/invitations/accept';
4
4
  const DEFAULT_LOGOUT_REDIRECT = LOGIN_PATH;
5
5
  const TRAILING_SLASHES = /\/+$/;
6
+ const MAX_PATH_DECODE_ITERATIONS = 10;
6
7
 
7
- function resolveRedirectPath(value: unknown): URL | undefined {
8
+ export interface ResolvedRedirectPath {
9
+ pathname: string;
10
+ redirect: string;
11
+ target: URL;
12
+ }
13
+
14
+ // Repeated decoding can reveal `.`/`..` segments or a `//host` prefix that were
15
+ // hidden behind extra encoding layers (e.g. `%25252e%25252e` -> `..`). Those only
16
+ // get collapsed and re-checked against the origin by feeding the fully-decoded,
17
+ // percent-free string back through the URL parser; without this, a multiply
18
+ // encoded `/safe/../auth/login` or `/safe/../invitations/accept?token=...`
19
+ // evades the pathname checks below even though this loop "sees" the real path.
20
+ function decodePathToFixedPoint(pathname: string): string | undefined {
21
+ let current = pathname;
22
+
23
+ for (let iteration = 0; iteration < MAX_PATH_DECODE_ITERATIONS; iteration += 1) {
24
+ let decoded: string;
25
+ try {
26
+ decoded = decodeURIComponent(current);
27
+ } catch {
28
+ return undefined;
29
+ }
30
+ if (decoded === current) return current;
31
+ current = decoded;
32
+ }
33
+
34
+ return undefined;
35
+ }
36
+
37
+ export function resolveRedirectPath(value: unknown): ResolvedRedirectPath | undefined {
8
38
  if (typeof value !== 'string' || !value.startsWith('/')) return undefined;
9
39
  let decoded: string;
10
40
  try {
@@ -19,27 +49,36 @@ function resolveRedirectPath(value: unknown): URL | undefined {
19
49
  return undefined;
20
50
  }
21
51
  if (target.origin !== REDIRECT_ORIGIN) return undefined;
22
- return target;
52
+ const decodedPathname = decodePathToFixedPoint(target.pathname);
53
+ if (decodedPathname === undefined) return undefined;
54
+ let canonical: URL;
55
+ try {
56
+ canonical = new URL(decodedPathname, REDIRECT_ORIGIN);
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ if (canonical.origin !== REDIRECT_ORIGIN) return undefined;
61
+ return {pathname: canonical.pathname, redirect: formatRedirectPath(target), target};
23
62
  }
24
63
 
25
64
  function formatRedirectPath(target: URL): string {
26
65
  return `${target.pathname}${target.search}${target.hash}`;
27
66
  }
28
67
 
29
- function isAuthPath(pathname: string): boolean {
68
+ export function isAuthPath(pathname: string): boolean {
30
69
  return pathname === '/auth' || pathname.startsWith('/auth/');
31
70
  }
32
71
 
33
72
  // Resolves and canonicalizes an internal path before returning it, so browser URL
34
73
  // parsing cannot turn a seemingly safe path into an external or auth route.
35
74
  export function sanitizeRedirectPath(value: unknown): string | undefined {
36
- const target = resolveRedirectPath(value);
37
- if (!target || isAuthPath(target.pathname)) return undefined;
38
- return formatRedirectPath(target);
75
+ const resolved = resolveRedirectPath(value);
76
+ if (!resolved || isAuthPath(resolved.pathname)) return undefined;
77
+ return resolved.redirect;
39
78
  }
40
79
 
41
- function containsInvitationToken(target: URL): boolean {
42
- const normalizedPathname = target.pathname.replace(TRAILING_SLASHES, '') || '/';
80
+ function containsInvitationToken(target: URL, pathname: string): boolean {
81
+ const normalizedPathname = pathname.replace(TRAILING_SLASHES, '') || '/';
43
82
  return normalizedPathname === INVITATION_ACCEPT_PATH && target.searchParams.has('token');
44
83
  }
45
84
 
@@ -50,10 +89,13 @@ function containsInvitationToken(target: URL): boolean {
50
89
  * survive this boundary. Invalid values fail closed to login.
51
90
  */
52
91
  export function sanitizeLogoutRedirectPath(value: unknown): string {
53
- const target = resolveRedirectPath(value);
54
- if (!target) return DEFAULT_LOGOUT_REDIRECT;
55
- if (isAuthPath(target.pathname) || containsInvitationToken(target)) {
92
+ const resolved = resolveRedirectPath(value);
93
+ if (!resolved) return DEFAULT_LOGOUT_REDIRECT;
94
+ if (
95
+ isAuthPath(resolved.pathname) ||
96
+ containsInvitationToken(resolved.target, resolved.pathname)
97
+ ) {
56
98
  return DEFAULT_LOGOUT_REDIRECT;
57
99
  }
58
- return formatRedirectPath(target);
100
+ return resolved.redirect;
59
101
  }
@@ -3,4 +3,4 @@ export {
3
3
  EmailCodeVerification,
4
4
  type EmailCodeVerificationProps,
5
5
  } from './components/email-code-verification.js';
6
- export {parseRedirectContext, type RedirectContext} from './components/redirect-context.js';
6
+ export {parseRedirectContext, type RedirectContext} from './redirect-context.js';
@@ -3,7 +3,7 @@ import {
3
3
  pendingInvitation,
4
4
  usePreviewInvitation,
5
5
  } from '@shipfox/client-invitations';
6
- import {parseRedirectContext} from '#/components/redirect-context.js';
6
+ import {parseRedirectContext} from '#/redirect-context.js';
7
7
 
8
8
  export function extractInvitationToken(redirect: unknown): string | undefined {
9
9
  return parseRedirectContext(redirect).invitationToken;
@@ -1,6 +1,12 @@
1
- import {parseRedirectContext} from './redirect-context.js';
1
+ import {parseRedirectContext, type RedirectContext} from '@shipfox/client-auth/redirect-context';
2
+
3
+ describe('@shipfox/client-auth/redirect-context', () => {
4
+ test('imports the parser and its type through the Node-safe public subpath', () => {
5
+ const context: RedirectContext = parseRedirectContext('/workspaces/acme');
6
+
7
+ expect(context).toEqual({returnTo: '/workspaces/acme'});
8
+ });
2
9
 
3
- describe('parseRedirectContext', () => {
4
10
  test('returns an ordinary safe return path', () => {
5
11
  const context = parseRedirectContext('/workspaces/acme?tab=runs');
6
12
 
@@ -22,12 +28,30 @@ describe('parseRedirectContext', () => {
22
28
  expect(context).toEqual({invitationToken: 'raw-invitation-token'});
23
29
  });
24
30
 
31
+ test.each([
32
+ ['/%2569nvitations/accept?token=double-encoded-token', 'double-encoded-token'],
33
+ ['/%252569nvitations/accept?token=triple-encoded-token', 'triple-encoded-token'],
34
+ [
35
+ '/safe%255c..%255cinvitations/accept?token=encoded-backslash-token',
36
+ 'encoded-backslash-token',
37
+ ],
38
+ ])('separates an invitation token from a deeply encoded path: %s', (redirect, token) => {
39
+ const context = parseRedirectContext(redirect);
40
+
41
+ expect(context).toEqual({invitationToken: token});
42
+ expect(context.returnTo).toBeUndefined();
43
+ });
44
+
25
45
  test.each([
26
46
  'https://attacker.example',
27
47
  '//attacker.example',
28
48
  '/auth/login',
29
49
  '/%61uth/login',
50
+ '/%2561uth/login',
51
+ '/%252561uth/login',
30
52
  '/%E0%80%80',
53
+ '/safe/%25252e%25252e/auth/login',
54
+ '/safe%255c..%255cauth/login',
31
55
  ])('rejects malformed or unsafe redirect %s', (redirect) => {
32
56
  const context = parseRedirectContext(redirect);
33
57
 
@@ -1,4 +1,4 @@
1
- import {sanitizeRedirectPath} from './redirect-target.js';
1
+ import {isAuthPath, resolveRedirectPath} from './components/redirect-target.js';
2
2
 
3
3
  const INVITATION_ACCEPT_PATH = '/invitations/accept';
4
4
 
@@ -13,14 +13,11 @@ export interface RedirectContext {
13
13
  * short-lived invitation flow instead of forwarding it through generic redirects.
14
14
  */
15
15
  export function parseRedirectContext(value: unknown): RedirectContext {
16
- const redirect = sanitizeRedirectPath(value);
17
- if (!redirect) return {};
16
+ const resolved = resolveRedirectPath(value);
17
+ if (!resolved || isAuthPath(resolved.pathname)) return {};
18
18
 
19
- const decoded = decodeURIComponent(redirect);
20
- const [path, queryString = ''] = decoded.split('?', 2);
21
- if (path !== INVITATION_ACCEPT_PATH) return {returnTo: redirect};
19
+ if (resolved.pathname !== INVITATION_ACCEPT_PATH) return {returnTo: resolved.redirect};
22
20
 
23
- const params = new URLSearchParams(queryString);
24
- const invitationToken = params.get('token');
21
+ const invitationToken = resolved.target.searchParams.get('token');
25
22
  return invitationToken ? {invitationToken} : {};
26
23
  }