Skip to content

Commit 1d8a1dd

Browse files
Merge remote-tracking branch 'origin/main' into chore/update-dependencies-95
# Conflicts: # package-lock.json # package.json
2 parents 24bcec0 + 259c5ff commit 1d8a1dd

14 files changed

Lines changed: 355 additions & 16 deletions

File tree

.github/ISSUE_TEMPLATE/config.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
blank_issues_enabled: true
1+
blank_issues_enabled: false
22
contact_links:
33
- name: Report a security vulnerability
44
url: https://github.com/encryption4all/postguard-business/security/advisories/new

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ jobs:
8282
node-version: 24
8383
cache: npm
8484
- run: npm ci
85-
- run: npx vitest run
85+
- run: npx vitest run --coverage
8686
env:
8787
DATABASE_URL: postgres://testuser:testpassword@localhost:5432/postguard_business_test
8888
- run: npx playwright install --with-deps

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ Thumbs.db
2222
vite.config.js.timestamp-*
2323
vite.config.ts.timestamp-*
2424

25+
# Vitest coverage
26+
/coverage
27+
2528
# Playwright
2629
test-results
2730
/playwright-report

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ bun.lockb
88
# Miscellaneous
99
/static/
1010
/drizzle/
11+
/coverage/

package-lock.json

Lines changed: 218 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"@sveltejs/kit": "^2.69.0",
3232
"@sveltejs/vite-plugin-svelte": "^7.1.2",
3333
"@types/node": "^26.1.0",
34+
"@vitest/coverage-v8": "^4.1.9",
3435
"drizzle-kit": "^0.31.10",
3536
"drizzle-orm": "^0.45.2",
3637
"eslint": "^10.6.0",

src/routes/(marketing)/+layout.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
showRegister={data.marketingFlags.registration}
1212
auth={data.auth}
1313
/>
14-
<main>
14+
<main id="main-content">
1515
{@render children()}
1616
</main>
1717
<Footer />
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { redirect } from '@sveltejs/kit';
22
import type { PageServerLoad } from './$types';
3+
import { safeRedirect } from './safe-redirect';
34

45
export const load: PageServerLoad = async ({ locals, url }) => {
56
if (locals.session?.userType === 'org') {
6-
redirect(303, url.searchParams.get('redirect') ?? '/portal/dashboard');
7+
redirect(303, safeRedirect(url.searchParams.get('redirect')));
78
}
89
};

src/routes/auth/login/+page.svelte

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@
88
import { goto } from '$app/navigation';
99
import { page } from '$app/state';
1010
import { resolve } from '$app/paths';
11+
import { safeRedirect } from './safe-redirect';
1112
import type { PageData } from './$types';
1213
1314
let { data }: { data: PageData } = $props();
1415
15-
const redirectTo = $derived(page.url.searchParams.get('redirect') ?? '/portal/dashboard');
16+
const redirectTo = $derived(page.url.searchParams.get('redirect'));
1617
1718
function handleSuccess() {
18-
goto(resolve(redirectTo as '/portal/dashboard'));
19+
// The `redirect` param is attacker-controlled — validate it before navigating.
20+
goto(resolve(safeRedirect(redirectTo) as '/portal/dashboard'));
1921
}
2022
</script>
2123

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// The post-login `redirect` query param is attacker-controlled, so it must
2+
// never be handed to `redirect()`/`goto()` without validation — otherwise it
3+
// is an open redirect (an attacker can send `?redirect=https://evil.example`
4+
// or the protocol-relative `?redirect=//evil.example` to bounce a logged-in
5+
// user off-site). Only same-origin, path-absolute targets are allowed.
6+
7+
export const DEFAULT_REDIRECT = '/portal/dashboard';
8+
9+
/**
10+
* True if `value` contains any C0 control character (U+0000–U+001F, incl.
11+
* TAB/LF/CR). Browsers strip these from URLs before navigating, so a value
12+
* like `/\t/evil.example` or `/\n//evil.example` would slip past the slash
13+
* checks below and then normalise to a protocol-relative, off-origin URL.
14+
* Strip-then-parse is the classic open-redirect bypass, so we reject any such
15+
* value up front. (Expressed as a char-code scan to keep literal control
16+
* characters out of the source.)
17+
*/
18+
function hasControlChar(value: string): boolean {
19+
for (let i = 0; i < value.length; i++) {
20+
if (value.charCodeAt(i) <= 0x1f) return true;
21+
}
22+
return false;
23+
}
24+
25+
/**
26+
* Return a safe post-login redirect target. A value is only accepted when it
27+
* begins with a single `/` (a same-origin absolute path). Everything else —
28+
* empty/missing values, values containing control characters, absolute URLs
29+
* (`https://…`), protocol-relative URLs (`//host`) and the backslash variant
30+
* browsers normalise to them (`/\host`) — falls back to {@link DEFAULT_REDIRECT}.
31+
*/
32+
export function safeRedirect(target: string | null | undefined): string {
33+
if (!target) return DEFAULT_REDIRECT;
34+
if (hasControlChar(target)) return DEFAULT_REDIRECT;
35+
if (!target.startsWith('/')) return DEFAULT_REDIRECT;
36+
// Reject protocol-relative URLs. Browsers treat `\` as `/`, so `/\host`
37+
// escapes the origin just like `//host` does.
38+
if (target[1] === '/' || target[1] === '\\') return DEFAULT_REDIRECT;
39+
return target;
40+
}

0 commit comments

Comments
 (0)