auth: validate URL-supplied token before persisting + tighten guards to require populated user #36

Closed
opened 2026-05-03 07:31:31 +00:00 by padreug · 0 comments
Owner

Summary

Pre-#11 auth hardening that closes a concrete access-control gap discovered when an unauthenticated tester reached /market/dashboard ("My Store") on the demo standalone.

Risk: HIGH — both findings combined produced an actual bypass.

Background

The standalone app shells call acceptTokenFromUrl() synchronously at boot in every per-app app.ts:

function acceptTokenFromUrl() {
  const params = new URLSearchParams(window.location.search)
  const token = params.get('token')
  if (token) {
    localStorage.setItem('lnbits_access_token', token)  // ← unvalidated
    ...
  }
}

Files: src/market-app/app.ts:17-29, src/wallet-app/app.ts:22-33, src/chat-app/app.ts, src/forum-app/app.ts, src/tasks-app/app.ts, src/activities-app/app.ts:22-33, src/accounting-app/app.ts:24-36.

LnbitsAPI constructor (src/lib/api/lnbits.ts:80) reads lnbits_access_token from localStorage at instantiation. lnbitsAPI.isAuthenticated() (:194-196) returns !!this.accessToken — token presence only, no server confirmation.

The router guards in src/lib/router-helpers.ts only check auth.isAuthenticated.value:

export function installStrictAuthGuard(router: Router): void {
  router.beforeEach(async (to) => {
    const auth = await authReady
    if (to.path === '/login') {
      return auth.isAuthenticated.value ? '/' : true
    }
    return auth.isAuthenticated.value ? true : '/login'
  })
}

Combined: any string in ?token=… becomes a "valid" session as far as the guard is concerned, until auth.initialize() makes a server round-trip and the API rejects it. There's a window in which guards permit navigation with no real user context.

Repro

https://demo.${domain}/market/?token=anything-here

Open in a clean browser. Until the first failing API call, the dashboard route is reachable and auth.isAuthenticated.value === true.

Acceptance criteria

1. Validate URL-supplied tokens server-side before persisting

acceptTokenFromUrl() MUST NOT write directly to lnbits_access_token. Suggested approach:

  • Write incoming URL token to a transient key (e.g. lnbits_pending_token) and strip it from the URL.
  • During auth.initialize(), if lnbits_pending_token exists:
    1. Attempt getCurrentUser() using that token
    2. On success → promote it to lnbits_access_token, populate user.value
    3. On failure → delete the pending token, do nothing else (no auth state)
  • Apply consistently across all 7 per-app app.ts shells.

2. Guards require BOTH isAuthenticated AND a populated user object

installStrictAuthGuard and installLenientAuthGuard in src/lib/router-helpers.ts should compute:

const isFullyAuthed = auth.isAuthenticated.value && !!auth.currentUser?.value?.pubkey

and use that everywhere they currently use auth.isAuthenticated.value. The AuthLike type needs currentUser added.

3. Per-route auth gate inside dashboards (defence in depth)

MarketDashboard.vue and any other auth-gated view should also check at mount time and route-redirect to /login if the auth state isn't fully populated. Belt-and-suspenders against future guard regressions.

Out of scope

  • Centralised SigningService (#11)
  • Vue DevTools / debug-globals hardening (#11 checklist)
  • CSP / nginx security headers (separate issue)
  • NIP-46 / Amber signer integration (#11)

Notes

  • The "I made a market item without logging in" anecdote that surfaced this is mostly the cross-origin per-port localStorage isolation in dev (each port is its own origin). In path-mode prod (single origin) that specific quirk collapses, but the URL-token vulnerability above stays.
  • File audit doc was referenced in #11 but doesn't exist on disk anymore (misc-docs/SECURITY_AUDIT_PRVKEY.md).
## Summary Pre-#11 auth hardening that closes a concrete access-control gap discovered when an unauthenticated tester reached `/market/dashboard` ("My Store") on the demo standalone. **Risk: HIGH** — both findings combined produced an actual bypass. ## Background The standalone app shells call `acceptTokenFromUrl()` synchronously at boot in every per-app `app.ts`: ```ts function acceptTokenFromUrl() { const params = new URLSearchParams(window.location.search) const token = params.get('token') if (token) { localStorage.setItem('lnbits_access_token', token) // ← unvalidated ... } } ``` Files: `src/market-app/app.ts:17-29`, `src/wallet-app/app.ts:22-33`, `src/chat-app/app.ts`, `src/forum-app/app.ts`, `src/tasks-app/app.ts`, `src/activities-app/app.ts:22-33`, `src/accounting-app/app.ts:24-36`. `LnbitsAPI` constructor (`src/lib/api/lnbits.ts:80`) reads `lnbits_access_token` from localStorage at instantiation. `lnbitsAPI.isAuthenticated()` (`:194-196`) returns `!!this.accessToken` — token presence only, no server confirmation. The router guards in `src/lib/router-helpers.ts` only check `auth.isAuthenticated.value`: ```ts export function installStrictAuthGuard(router: Router): void { router.beforeEach(async (to) => { const auth = await authReady if (to.path === '/login') { return auth.isAuthenticated.value ? '/' : true } return auth.isAuthenticated.value ? true : '/login' }) } ``` Combined: any string in `?token=…` becomes a "valid" session as far as the guard is concerned, until `auth.initialize()` makes a server round-trip and the API rejects it. There's a window in which guards permit navigation with no real user context. ## Repro ``` https://demo.${domain}/market/?token=anything-here ``` Open in a clean browser. Until the first failing API call, the dashboard route is reachable and `auth.isAuthenticated.value === true`. ## Acceptance criteria ### 1. Validate URL-supplied tokens server-side before persisting `acceptTokenFromUrl()` MUST NOT write directly to `lnbits_access_token`. Suggested approach: - Write incoming URL token to a transient key (e.g. `lnbits_pending_token`) and strip it from the URL. - During `auth.initialize()`, if `lnbits_pending_token` exists: 1. Attempt `getCurrentUser()` using that token 2. On success → promote it to `lnbits_access_token`, populate `user.value` 3. On failure → delete the pending token, do nothing else (no auth state) - Apply consistently across all 7 per-app `app.ts` shells. ### 2. Guards require BOTH `isAuthenticated` AND a populated user object `installStrictAuthGuard` and `installLenientAuthGuard` in `src/lib/router-helpers.ts` should compute: ```ts const isFullyAuthed = auth.isAuthenticated.value && !!auth.currentUser?.value?.pubkey ``` and use that everywhere they currently use `auth.isAuthenticated.value`. The `AuthLike` type needs `currentUser` added. ### 3. Per-route auth gate inside dashboards (defence in depth) `MarketDashboard.vue` and any other auth-gated view should also check at mount time and route-redirect to `/login` if the auth state isn't fully populated. Belt-and-suspenders against future guard regressions. ## Out of scope - Centralised SigningService (#11) - Vue DevTools / debug-globals hardening (#11 checklist) - CSP / nginx security headers (separate issue) - NIP-46 / Amber signer integration (#11) ## Notes - The "I made a market item without logging in" anecdote that surfaced this is mostly the **cross-origin per-port localStorage isolation** in dev (each port is its own origin). In path-mode prod (single origin) that specific quirk collapses, but the URL-token vulnerability above stays. - File audit doc was referenced in #11 but doesn't exist on disk anymore (`misc-docs/SECURITY_AUDIT_PRVKEY.md`).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/webapp#36
No description provided.