mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 311647fd46 | |||
| 28dbd86d03 | |||
| 842d9760df | |||
| 58218ff5f6 | |||
| 83be5fc92a | |||
| 7798d2a3fd |
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 3.0.2
|
||||
version: 3.0.5
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "3.0.2"
|
||||
appVersion: "3.0.5"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "3.0.2",
|
||||
"version": "3.0.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trek-client",
|
||||
"version": "3.0.2",
|
||||
"version": "3.0.5",
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"axios": "^1.6.7",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "3.0.2",
|
||||
"version": "3.0.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function ConfirmDialog({
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingBottom: 'var(--bottom-nav-h)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function CopyTripDialog({ isOpen, tripTitle, onClose, onConfirm }
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingBottom: 'var(--bottom-nav-h)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -32,6 +32,13 @@ function triggerAnchorDownload(blobUrl: string, filename?: string): void {
|
||||
setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 100)
|
||||
}
|
||||
|
||||
// navigator.standalone is true only on iOS when running as an
|
||||
// add-to-home-screen PWA. In that context, target="_blank" hands off to
|
||||
// Safari, which cannot access blob URLs sandboxed to the WebView.
|
||||
function isIosStandalone(): boolean {
|
||||
return (navigator as any).standalone === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a protected file using cookie auth (credentials: include) and
|
||||
* triggers a browser download. Works inside PWA standalone mode because the
|
||||
@@ -56,7 +63,13 @@ export async function downloadFile(url: string, filename?: string): Promise<void
|
||||
* (including text/html and image/svg+xml which can execute script) are forced
|
||||
* to download so that an uploaded file cannot run code in the TREK origin.
|
||||
*
|
||||
* Falls back to a download trigger if the popup is blocked.
|
||||
* Uses a synthetic <a target="_blank" rel="noopener noreferrer"> click rather
|
||||
* than window.open(). window.open() called with the "noreferrer"/"noopener"
|
||||
* window feature returns null per spec, which previously made the popup-block
|
||||
* fallback trigger a download in the *current* tab on top of the new-tab open
|
||||
* — i.e. the file opened twice. The anchor approach avoids that ambiguity:
|
||||
* the new tab is opened by the browser's normal link-handling path, and no
|
||||
* spurious in-page download is triggered.
|
||||
*/
|
||||
export async function openFile(url: string, filename?: string): Promise<void> {
|
||||
assertRelativeUrl(url)
|
||||
@@ -71,11 +84,19 @@ export async function openFile(url: string, filename?: string): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
const win = window.open(blobUrl, '_blank', 'noreferrer')
|
||||
if (win) {
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
|
||||
} else {
|
||||
// Popup blocked — fall back to download
|
||||
// iOS PWA: target="_blank" would open Safari, which can't access the blob
|
||||
if (isIosStandalone()) {
|
||||
triggerAnchorDownload(blobUrl, filename)
|
||||
return
|
||||
}
|
||||
|
||||
const a = document.createElement('a')
|
||||
a.href = blobUrl
|
||||
a.target = '_blank'
|
||||
a.rel = 'noopener noreferrer'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
// Keep the blob URL alive long enough for the new tab to load it, then
|
||||
// clean up the DOM node and revoke the URL.
|
||||
setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 30_000)
|
||||
}
|
||||
|
||||
@@ -74,32 +74,42 @@ describe('downloadFile', () => {
|
||||
})
|
||||
|
||||
describe('openFile', () => {
|
||||
it('fetches with credentials:include and opens blob URL in new tab', async () => {
|
||||
it('fetches with credentials:include and opens blob URL via target=_blank anchor', async () => {
|
||||
vi.stubGlobal('fetch', makeFetchMock(200))
|
||||
const mockWin = { closed: false }
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue(mockWin as Window)
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
|
||||
await openFile('/uploads/files/doc.pdf')
|
||||
|
||||
expect(window.fetch).toHaveBeenCalledWith('/uploads/files/doc.pdf', { credentials: 'include' })
|
||||
expect(URL.createObjectURL).toHaveBeenCalled()
|
||||
expect(openSpy).toHaveBeenCalledWith('blob:mock-url', '_blank', 'noreferrer')
|
||||
// Must NOT call window.open — that path returns null when noreferrer is
|
||||
// set, which previously caused the file to also open in the current tab.
|
||||
expect(openSpy).not.toHaveBeenCalled()
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
// The anchor used to open the new tab must be target=_blank, must NOT
|
||||
// carry a `download` attribute (otherwise it would download in-page
|
||||
// instead of opening), and must use rel=noopener noreferrer.
|
||||
const appendCalls = (document.body.appendChild as ReturnType<typeof vi.fn>).mock.calls
|
||||
const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
|
||||
expect(anchor.target).toBe('_blank')
|
||||
expect(anchor.rel).toBe('noopener noreferrer')
|
||||
expect(anchor.hasAttribute('download')).toBe(false)
|
||||
|
||||
// Revoke happens after 30s timeout
|
||||
vi.runAllTimers()
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
||||
})
|
||||
|
||||
it('falls back to anchor download when popup is blocked', async () => {
|
||||
it('does not trigger a second in-page action for safe inline types (regression: no double-open)', async () => {
|
||||
vi.stubGlobal('fetch', makeFetchMock(200))
|
||||
vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
|
||||
await openFile('/uploads/files/doc.pdf')
|
||||
await openFile('/uploads/files/doc.pdf', 'doc.pdf')
|
||||
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
vi.runAllTimers()
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
||||
// Exactly ONE anchor click — opening the new tab. No fallback download.
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('throws on 401 response', async () => {
|
||||
@@ -108,28 +118,55 @@ describe('openFile', () => {
|
||||
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forces download for unsafe MIME types (HTML, SVG) instead of opening inline', async () => {
|
||||
it('forces download for unsafe MIME types (HTML) instead of opening inline', async () => {
|
||||
const htmlBlob = new Blob(['<script>alert(1)</script>'], { type: 'text/html' })
|
||||
vi.stubGlobal('fetch', makeFetchMock(200, htmlBlob))
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window)
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
|
||||
await openFile('/uploads/files/malicious.html')
|
||||
await openFile('/uploads/files/malicious.html', 'malicious.html')
|
||||
|
||||
// Must NOT open inline — download anchor clicked instead
|
||||
expect(openSpy).not.toHaveBeenCalled()
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
const appendCalls = (document.body.appendChild as ReturnType<typeof vi.fn>).mock.calls
|
||||
const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
|
||||
expect(anchor.download).toBe('malicious.html')
|
||||
})
|
||||
|
||||
it('forces download for SVG MIME type', async () => {
|
||||
const svgBlob = new Blob(['<svg><script>alert(1)</script></svg>'], { type: 'image/svg+xml' })
|
||||
vi.stubGlobal('fetch', makeFetchMock(200, svgBlob))
|
||||
vi.spyOn(window, 'open').mockReturnValue({} as Window)
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window)
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
|
||||
await openFile('/uploads/files/malicious.svg')
|
||||
|
||||
expect(window.open).not.toHaveBeenCalled()
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
expect(openSpy).not.toHaveBeenCalled()
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('falls back to download in iOS PWA standalone mode (blob URL inaccessible to Safari)', async () => {
|
||||
vi.stubGlobal('fetch', makeFetchMock(200))
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
// Simulate iOS PWA (Add-to-Home-Screen) context
|
||||
Object.defineProperty(navigator, 'standalone', { configurable: true, value: true })
|
||||
|
||||
try {
|
||||
await openFile('/uploads/files/doc.pdf', 'doc.pdf')
|
||||
|
||||
// Single anchor click — and it must be a DOWNLOAD anchor (no target=_blank),
|
||||
// because target="_blank" in iOS PWA would hand off to Safari which cannot
|
||||
// read the in-WebView blob URL.
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||
const appendCalls = (document.body.appendChild as ReturnType<typeof vi.fn>).mock.calls
|
||||
const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
|
||||
expect(anchor.target).toBe('')
|
||||
expect(anchor.download).toBe('doc.pdf')
|
||||
} finally {
|
||||
// Clean up the non-standard iOS-only property we forced above.
|
||||
delete (navigator as any).standalone
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trek-server",
|
||||
"version": "3.0.2",
|
||||
"version": "3.0.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trek-server",
|
||||
"version": "3.0.2",
|
||||
"version": "3.0.5",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"archiver": "^6.0.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-server",
|
||||
"version": "3.0.2",
|
||||
"version": "3.0.5",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node --import tsx src/index.ts",
|
||||
|
||||
@@ -112,7 +112,7 @@ router.get('/callback', async (req: Request, res: Response) => {
|
||||
tokenData.id_token,
|
||||
doc,
|
||||
config.clientId,
|
||||
config.issuer,
|
||||
(doc.issuer ?? '').replace(/\/+$/, '') || config.issuer,
|
||||
);
|
||||
if (idVerify.ok !== true) {
|
||||
const reason = 'error' in idVerify ? idVerify.error : 'unknown';
|
||||
|
||||
@@ -140,11 +140,21 @@ export async function discover(issuer: string, discoveryUrl?: string | null): Pr
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error('Failed to fetch OIDC discovery document');
|
||||
const doc = (await res.json()) as OidcDiscoveryDoc;
|
||||
// Validate that the discovery doc's issuer matches the operator-configured
|
||||
// one. A MITM or compromised doc could otherwise supply a crafted issuer
|
||||
// that passes jwt.verify() because we used doc.issuer as the expected value.
|
||||
if (doc.issuer && doc.issuer.replace(/\/+$/, '') !== issuer) {
|
||||
throw new Error(`OIDC discovery issuer mismatch: expected "${issuer}", got "${doc.issuer}"`);
|
||||
// Validate that the discovery doc's issuer matches the operator-configured one.
|
||||
// When no custom discoveryUrl is set, a mismatch signals a MITM or misconfiguration
|
||||
// and we reject. When the operator explicitly overrides the discovery URL (e.g.
|
||||
// Authentik realm paths), the discovery doc's issuer is the canonical value —
|
||||
// trust it and warn rather than blocking login.
|
||||
const docIssuer = doc.issuer?.replace(/\/+$/, '') ?? '';
|
||||
if (docIssuer && docIssuer !== issuer) {
|
||||
if (discoveryUrl) {
|
||||
console.warn(
|
||||
`[OIDC] Discovery doc issuer "${doc.issuer}" differs from configured OIDC_ISSUER "${issuer}". ` +
|
||||
`Using discovery doc issuer for id_token verification (custom OIDC_DISCOVERY_URL is set).`,
|
||||
);
|
||||
} else {
|
||||
throw new Error(`OIDC discovery issuer mismatch: expected "${issuer}", got "${doc.issuer}"`);
|
||||
}
|
||||
}
|
||||
doc._issuer = url;
|
||||
discoveryCache = doc;
|
||||
@@ -313,7 +323,6 @@ export async function verifyIdToken(
|
||||
try {
|
||||
const verified = jwt.verify(idToken, publicKey, {
|
||||
algorithms: [alg as jwt.Algorithm],
|
||||
issuer: expectedIssuer,
|
||||
audience: clientId,
|
||||
});
|
||||
claims = typeof verified === 'string' ? {} : (verified as Record<string, unknown>);
|
||||
@@ -322,6 +331,13 @@ export async function verifyIdToken(
|
||||
return { ok: false, error: `signature_or_claim_mismatch: ${msg}` };
|
||||
}
|
||||
|
||||
// Normalize trailing slash before issuer comparison — some IdPs (e.g. Authentik)
|
||||
// include a trailing slash in the id_token iss claim.
|
||||
const tokenIssuer = typeof claims['iss'] === 'string' ? claims['iss'].replace(/\/+$/, '') : '';
|
||||
if (tokenIssuer !== expectedIssuer) {
|
||||
return { ok: false, error: `signature_or_claim_mismatch: jwt issuer invalid. expected: ${expectedIssuer}` };
|
||||
}
|
||||
|
||||
return { ok: true, claims };
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* discover caching, and the ReDoS-sensitive issuer trailing-slash regex.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
import { generateKeyPairSync } from 'crypto';
|
||||
import jwtLib from 'jsonwebtoken';
|
||||
|
||||
// ── DB setup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -50,6 +52,7 @@ import {
|
||||
frontendUrl,
|
||||
findOrCreateUser,
|
||||
discover,
|
||||
verifyIdToken,
|
||||
} from '../../../src/services/oidcService';
|
||||
|
||||
const MOCK_CONFIG = {
|
||||
@@ -216,6 +219,59 @@ describe('discover', () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
|
||||
await expect(discover('https://bad-issuer.example.com')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('OIDC-SVC-037: accepts mismatched doc issuer when discoveryUrl is explicit', async () => {
|
||||
const doc = {
|
||||
issuer: 'https://auth.example.com/application/o/myapp/',
|
||||
authorization_endpoint: 'https://auth.example.com/application/o/myapp/authorize/',
|
||||
token_endpoint: 'https://auth.example.com/application/o/token/',
|
||||
userinfo_endpoint: 'https://auth.example.com/application/o/userinfo/',
|
||||
};
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc }));
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const result = await discover(
|
||||
'https://auth.example.com',
|
||||
'https://auth.example.com/application/o/myapp/.well-known/openid-configuration',
|
||||
);
|
||||
|
||||
expect(result.issuer).toBe(doc.issuer);
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('differs from configured OIDC_ISSUER'));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('OIDC-SVC-038: throws on mismatched doc issuer when discoveryUrl is omitted', async () => {
|
||||
const doc = {
|
||||
issuer: 'https://evil.example.com',
|
||||
authorization_endpoint: 'https://unique-2.example.com/auth',
|
||||
token_endpoint: 'https://unique-2.example.com/token',
|
||||
userinfo_endpoint: 'https://unique-2.example.com/userinfo',
|
||||
};
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc }));
|
||||
|
||||
await expect(discover('https://unique-2.example.com')).rejects.toThrow(
|
||||
'OIDC discovery issuer mismatch',
|
||||
);
|
||||
});
|
||||
|
||||
it('OIDC-SVC-039: trailing-slash-only mismatch with explicit discoveryUrl does not warn', async () => {
|
||||
const doc = {
|
||||
issuer: 'https://auth.example.com/',
|
||||
authorization_endpoint: 'https://auth.example.com/auth',
|
||||
token_endpoint: 'https://auth.example.com/token',
|
||||
userinfo_endpoint: 'https://auth.example.com/userinfo',
|
||||
};
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc }));
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
await discover(
|
||||
'https://auth.example.com',
|
||||
'https://auth.example.com/.well-known/openid-configuration',
|
||||
);
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ── issuer trailing-slash regex (ReDoS guard) ─────────────────────────────────
|
||||
@@ -460,3 +516,66 @@ describe('getUserInfo', () => {
|
||||
expect(fetchCall[1].headers.Authorization).toBe('Bearer access-token-123');
|
||||
});
|
||||
});
|
||||
|
||||
// ── verifyIdToken ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('verifyIdToken', () => {
|
||||
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
const jwk = publicKey.export({ format: 'jwk' }) as Record<string, unknown>;
|
||||
const ISSUER = 'https://auth.example.com/application/o/trek';
|
||||
const CLIENT_ID = 'trek-client';
|
||||
const JWKS_URI = 'https://auth.example.com/.well-known/jwks.json';
|
||||
|
||||
function mockJwks() {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ keys: [jwk] }),
|
||||
}));
|
||||
}
|
||||
|
||||
function makeToken(iss: string, overrides: object = {}) {
|
||||
return jwtLib.sign(
|
||||
{ sub: 'user-sub', email: 'user@example.com', ...overrides },
|
||||
privateKey,
|
||||
{ algorithm: 'RS256', audience: CLIENT_ID, issuer: iss, expiresIn: '1h' }
|
||||
);
|
||||
}
|
||||
|
||||
const doc = { jwks_uri: JWKS_URI } as any;
|
||||
|
||||
afterEach(() => { vi.unstubAllGlobals(); });
|
||||
|
||||
it('OIDC-SVC-033: accepts token whose iss matches expectedIssuer exactly', async () => {
|
||||
mockJwks();
|
||||
const token = makeToken(ISSUER);
|
||||
const result = await verifyIdToken(token, doc, CLIENT_ID, ISSUER);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('OIDC-SVC-034: accepts token whose iss has a trailing slash (Authentik)', async () => {
|
||||
mockJwks();
|
||||
const token = makeToken(ISSUER + '/');
|
||||
const result = await verifyIdToken(token, doc, CLIENT_ID, ISSUER);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('OIDC-SVC-035: rejects token with wrong issuer', async () => {
|
||||
mockJwks();
|
||||
const token = makeToken('https://evil.example.com');
|
||||
const result = await verifyIdToken(token, doc, CLIENT_ID, ISSUER);
|
||||
expect(result.ok).toBe(false);
|
||||
expect((result as any).error).toMatch('jwt issuer invalid');
|
||||
});
|
||||
|
||||
it('OIDC-SVC-036: rejects token with wrong audience', async () => {
|
||||
mockJwks();
|
||||
const token = makeToken(ISSUER, {});
|
||||
const wrongAudToken = jwtLib.sign(
|
||||
{ sub: 'user-sub', iss: ISSUER },
|
||||
privateKey,
|
||||
{ algorithm: 'RS256', audience: 'wrong-client', expiresIn: '1h' }
|
||||
);
|
||||
const result = await verifyIdToken(wrongAudToken, doc, CLIENT_ID, ISSUER);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user