TDD: Fix Unauthorized UI Leaks (Issue #304)
Issue: #304 - Application leaks UI elements to unauthorized users
Branch: bug/304-unauthorized-ui-leaks
Status: Planning
Date: 2025-11-21
Table of Contents
- Problem Statement
- Current Behavior
- Root Cause Analysis
- Proposed Solution
- Implementation Details
- Testing Strategy
- Security Considerations
- Files Modified
Problem Statement
When unauthorized users (not on the allowlist) visit the application, they can see UI elements that leak information about the application during the authorization check process. This creates both a security concern and a poor user experience.
Security Issues
- Balance Display Leak: Top app bar shows
$0.00balance, revealing billing/pricing structure - Chat Functionality Leak: Chat FAB button is visible and clickable, exposing feature set
- UI Structure Leak: Users can infer application architecture and available features
- Information Disclosure: Unauthorized users should see NOTHING except the authorization error
UX Issues
- Project Chooser Dialog: Shows "Failed to load projects" even when user is unauthorized
- Confusing Error Messages: Unauthorized message appears behind the project chooser dialog
- Inconsistent Styling: Inline unauthorized message doesn't match application design
- Race Condition: UI elements flash/appear before authorization check completes
Current Behavior
Scenario: Unauthorized User Visits Application
Test User: alex@permitproof.com (not allowlisted)
Test URL: test.m3.codeproof.app
Timeline of Events:
1. User navigates to app
2. Firebase authentication succeeds ✅
3. App shows loading screen
4. Authorization check begins (isAllowListed() called)
5. ⚠️ Top app bar renders (showing $0.00 balance)
6. ⚠️ Chat FAB renders (clickable)
7. ⚠️ Project chooser dialog opens
8. Authorization check fails ❌
9. Unauthorized message appears (behind dialog)
10. ⚠️ Project chooser shows "Failed to load projects"
Screenshots from Issue
Screenshot 1: Initial State
- Top app bar is visible with $0.00 balance
- Chat button is visible and clickable
- Loading state for projects
Screenshot 2: After Projects Load
- Unauthorized error message in background
- Project chooser dialog in foreground showing "Failed to load projects"
- Confusing dual-error state
Root Cause Analysis
1. Template Structure Issue
File: web-ng-m3/src/app/app.component.html
<ng-container *ngIf="loading$ | async; else notLoading">
<!-- Loading screen -->
</ng-container>
<ng-template #notLoading>
<ng-container *ngIf="unauthorized$ | async; else notUnauthorized">
<!-- ISSUE: Inline unauthorized message (basic HTML) -->
<div class="unauthorized-message">
<h2>Unauthorized</h2>
<p>Your account is not allowlisted for this application.</p>
<button mat-raised-button color="primary" (click)="signOut()">Sign out</button>
</div>
</ng-container>
<ng-template #notUnauthorized>
<ng-container *ngIf="needsSignIn$ | async; else authorized">
<!-- Sign-in screen -->
</ng-container>
<ng-template #authorized>
<!-- ISSUE: All UI elements render here -->
<app-admin-top-app-bar *ngIf="..."></app-admin-top-app-bar>
<app-project-top-app-bar *ngIf="..."></app-project-top-app-bar>
<app-billing-top-app-bar *ngIf="..."></app-billing-top-app-bar>
<app-landing-top-app-bar *ngIf="..."></app-landing-top-app-bar>
<router-outlet></router-outlet>
<!-- ISSUE: Chat components render for all authorized users -->
<app-chat></app-chat>
<app-chat-fab></app-chat-fab>
</ng-template>
</ng-template>
</ng-template>
Problems:
- Inline unauthorized message: Uses basic HTML instead of styled
UnauthorizedComponent - Chat components always render: No conditional to hide during authorization check
- Top app bars render: All app bars (with balance info) render in authorized template
- Race condition:
unauthorized$may not be set before routing/dialogs occur
2. Project Chooser Race Condition
File: web-ng-m3/src/app/shared/project-routing.service.ts
async handleProjectRouting(): Promise<void> {
try {
console.log('[ProjectRoutingService] Handling project routing');
this.showProjectSelectionDialog(); // ⚠️ No authorization check!
} catch (error) {
console.error('[ProjectRoutingService] Error handling project routing:', error);
this.snackBar.open('Failed to initialize project routing', 'Close', { duration: 3000 });
}
}
Problem: Dialog opens BEFORE authorization status is confirmed
3. Landing Page Race Condition
File: web-ng-m3/src/app/components/root/landing-page/landing-page.component.ts
The landing page may trigger project routing before authorization is fully checked.
4. Authorization Flow Timing
File: web-ng-m3/src/app/shared/firebase-auth.service.ts
public async isAllowListed(email: string): Promise<boolean> {
try {
// ... gRPC call to check authorization ...
return authorized;
} catch (error: any) {
// Sets unauthorized$ or navigates to /error
this.unauthorizedSubject.next(true);
this.router.navigate(['/unauthorized'], { queryParams: { email } });
return false;
}
}
Problem: Navigation happens, but template may have already rendered UI elements
Proposed Solution
High-Level Approach
- Use Proper Unauthorized Route: Navigate to
/unauthorizedroute instead of inline message - Guard Against Early Rendering: Ensure UI elements don't render during authorization check
- Prevent Dialog Race Condition: Add authorization check before opening project chooser
- Clean Template Hierarchy: Remove inline unauthorized message, rely on routing
Architecture Changes
BEFORE:
┌─────────────────────────────────────────┐
│ App Component │
│ ┌─────────────────────────────────────┐ │
│ │ If unauthorized$: │ │
│ │ Show inline HTML message │ │
│ │ Else: │ │
│ │ ✓ Show all app bars (with balance)│ │
│ │ ✓ Show chat components │ │
│ │ ✓ Open project chooser │ │
│ │ → Race condition! ⚠️ │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────┘
AFTER:
┌─────────────────────────────────────────┐
│ App Component │
│ ┌─────────────────────────────────────┐ │
│ │ If unauthorized$: │ │
│ │ → Navigate to /unauthorized │ │
│ │ → Router shows UnauthorizedComp │ │
│ │ ✗ NO app bars │ │
│ │ ✗ NO chat components │ │
│ │ ✗ NO dialogs │ │
│ │ Else if authorized: │ │
│ │ ✓ Show all app bars │ │
│ │ ✓ Show chat components │ │
│ │ ✓ Allow project routing │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────┘
Implementation Details
Change 1: Fix App Component Template
File: web-ng-m3/src/app/app.component.html
Current:
<ng-container *ngIf="unauthorized$ | async; else notUnauthorized">
<div class="unauthorized-message">
<h2>Unauthorized</h2>
<p>Your account is not allowlisted for this application.</p>
<button mat-raised-button color="primary" (click)="signOut()">Sign out</button>
</div>
</ng-container>
Proposed:
<!-- Option A: Use router-outlet for all cases -->
<ng-container *ngIf="loading$ | async; else notLoading">
<app-loading title="Initializing Application..." message="Please wait, we're getting things ready."></app-loading>
</ng-container>
<ng-template #notLoading>
<ng-container *ngIf="needsSignIn$ | async; else signedIn">
<div class="signin-message">
<app-loading title="Sign In Required" message="Redirecting to Google sign-in..."></app-loading>
</div>
</ng-container>
<ng-template #signedIn>
<!-- Router handles /unauthorized route automatically -->
<!-- Only show UI elements when NOT on /unauthorized route -->
<ng-container *ngIf="!(isUnauthorizedRoute$ | async)">
<app-global-progress></app-global-progress>
<!-- All app bars -->
<app-admin-top-app-bar *ngIf="(isAdminRoute$ | async) === true" ...></app-admin-top-app-bar>
<app-project-top-app-bar *ngIf="(isProjectRoute$ | async) === true" ...></app-project-top-app-bar>
<app-billing-top-app-bar *ngIf="(isBillingRoute$ | async) === true" ...></app-billing-top-app-bar>
<app-landing-top-app-bar *ngIf="(isLandingRoute$ | async) === true" ...></app-landing-top-app-bar>
<!-- Chat components -->
<app-chat></app-chat>
<app-chat-fab></app-chat-fab>
</ng-container>
<router-outlet></router-outlet>
</ng-template>
</ng-template>
Key Changes:
- Remove inline unauthorized message
- Add
isUnauthorizedRoute$observable to detect/unauthorizedroute - Conditionally hide all UI elements when on unauthorized route
- Let router handle unauthorized display via
UnauthorizedComponent
Change 2: Add Unauthorized Route Detection
File: web-ng-m3/src/app/app.component.ts
Add Observable:
isUnauthorizedRoute$: Observable<boolean>;
constructor(...) {
// ... existing code ...
// Detect /unauthorized and /error routes
this.isUnauthorizedRoute$ = this.router.events.pipe(
filter(event => event instanceof NavigationEnd),
map(() => {
const isUnauth = this.router.url.startsWith('/unauthorized') ||
this.router.url.startsWith('/error');
console.log(`[AppComponent] Unauthorized route check: ${isUnauth} (URL: "${this.router.url}")`);
return isUnauth;
}),
startWith(
this.router.url.startsWith('/unauthorized') ||
this.router.url.startsWith('/error')
),
distinctUntilChanged(),
shareReplay(1)
);
}
Change 3: Prevent Project Chooser for Unauthorized Users
File: web-ng-m3/src/app/shared/project-routing.service.ts
Current:
async handleProjectRouting(): Promise<void> {
try {
console.log('[ProjectRoutingService] Handling project routing');
this.showProjectSelectionDialog();
} catch (error) {
// ...
}
}
Proposed:
async handleProjectRouting(): Promise<void> {
try {
console.log('[ProjectRoutingService] Handling project routing');
// Check if user is authorized before showing dialog
const unauthorized = await this.firebaseAuthService.unauthorized$.pipe(take(1)).toPromise();
if (unauthorized) {
console.log('[ProjectRoutingService] User is unauthorized, skipping project routing');
return;
}
this.showProjectSelectionDialog();
} catch (error) {
console.error('[ProjectRoutingService] Error handling project routing:', error);
this.snackBar.open('Failed to initialize project routing', 'Close', { duration: 3000 });
}
}
Add Dependency Injection:
constructor(
private router: Router,
private dialog: MatDialog,
private snackBar: MatSnackBar,
private firebaseAuthService: FirebaseAuthService // Add this
) {}
Change 4: Guard Landing Page Project Routing
File: web-ng-m3/src/app/components/root/landing-page/landing-page.component.ts
Add Authorization Check:
async ngOnInit(): Promise<void> {
console.log('[LandingPageComponent] Initializing...');
// Wait for authorization check to complete
const unauthorized = await this.firebaseAuthService.unauthorized$.pipe(take(1)).toPromise();
if (unauthorized) {
console.log('[LandingPageComponent] User is unauthorized, skipping project routing');
return;
}
// Only proceed with project routing if authorized
await this.projectRoutingService.handleProjectRouting();
}
Change 5: Remove Inline Unauthorized Styles
File: web-ng-m3/src/app/app.component.css
Remove (if exists):
.unauthorized-message {
/* ... any styles for inline unauthorized message ... */
}
These styles are no longer needed since we use the styled UnauthorizedComponent.
Testing Strategy
Manual Testing Checklist
Test 1: Unauthorized User Experience
Setup:
- Deploy to test environment
- Use unauthorized email:
alex@permitproof.com
Steps:
- Navigate to
test.m3.codeproof.app - Sign in with
alex@permitproof.com - Wait for authorization check
Expected Results:
- ✅ Loading screen appears initially
- ✅ After auth check: Only UnauthorizedComponent is visible
- ✅ NO top app bar visible
- ✅ NO balance display visible
- ✅ NO chat FAB button visible
- ✅ NO project chooser dialog appears
- ✅ Clean, professional unauthorized message with proper styling
- ✅ "Sign out" button works correctly
Test 2: Authorized User Experience (Regression Test)
Setup:
- Deploy to test environment
- Use authorized email:
sanchos101@gmail.com
Steps:
- Navigate to
test.m3.codeproof.app - Sign in with authorized account
- Wait for initialization
Expected Results:
- ✅ Loading screen appears initially
- ✅ After auth check: Project chooser dialog appears
- ✅ Top app bar IS visible (with correct balance)
- ✅ Chat FAB button IS visible
- ✅ Can select/create projects normally
- ✅ All features work as before
Test 3: Route Navigation for Unauthorized Users
Setup:
- Already signed in as unauthorized user
Steps:
- Manually navigate to
/projects/some-id - Try to access
/admin - Try to access
/billing
Expected Results:
- ✅ All routes redirect to
/unauthorized - ✅ No UI leaks on any route
- ✅ Consistent unauthorized message
Test 4: Authorization Error Handling
Setup:
- Simulate server configuration error
Steps:
- Trigger authorization error (e.g., domain-wide delegation issue)
Expected Results:
- ✅ Navigates to
/errorroute - ✅ NO UI leaks
- ✅ ServerErrorComponent displays properly
Browser Testing
Test on:
- ✅ Chrome (latest)
- ✅ Firefox (latest)
- ✅ Safari (latest)
- ✅ Mobile (Chrome/Safari)
Performance Testing
- ✅ Verify no performance regression
- ✅ Check that authorization check doesn't slow down app load
- ✅ Ensure no unnecessary re-renders
Security Considerations
Information Disclosure Prevention
BEFORE (Security Issues):
- Balance amount revealed (
$0.00) → reveals pricing structure - Chat button → reveals feature availability
- Project chooser → reveals data structure
- UI patterns → allows reconnaissance
AFTER (Secure):
- ✅ Zero UI elements visible
- ✅ Only authorization message shown
- ✅ No inference about application structure possible
- ✅ Follows principle of least privilege
Defense in Depth
Even with these UI changes, we maintain backend security:
- ✅ All API endpoints still require authentication
- ✅ RBAC checks still enforced server-side
- ✅ Firestore rules prevent unauthorized access
- ✅ UI changes are additional layer, not primary security
Privacy Considerations
- ✅ Don't log sensitive user information
- ✅ Don't reveal why user is unauthorized (could be privacy issue)
- ✅ Generic error messages prevent information disclosure
Files Modified
Primary Changes
-
web-ng-m3/src/app/app.component.html- Remove inline unauthorized message
- Add conditional hiding of UI elements
- Let router handle unauthorized display
-
web-ng-m3/src/app/app.component.ts- Add
isUnauthorizedRoute$observable - Update constructor to initialize new observable
- Add
-
web-ng-m3/src/app/shared/project-routing.service.ts- Add authorization check before opening dialog
- Inject
FirebaseAuthService - Early return if unauthorized
-
web-ng-m3/src/app/components/root/landing-page/landing-page.component.ts- Add authorization check in
ngOnInit() - Skip project routing if unauthorized
- Add authorization check in
-
web-ng-m3/src/app/app.component.css- Remove inline unauthorized message styles (if any)
No Changes Required
-
web-ng-m3/src/app/components/root/unauthorized/unauthorized.component.html- Already properly styled ✅
-
web-ng-m3/src/app/components/root/unauthorized/unauthorized.component.scss- Already has good styling ✅
-
web-ng-m3/src/app/shared/firebase-auth.service.ts- Already handles authorization correctly ✅
Timeline
Estimated Effort: 2-3 hours
-
Implementation: 1 hour
- Template changes: 20 min
- TypeScript changes: 30 min
- Testing locally: 10 min
-
Testing: 1 hour
- Manual testing: 30 min
- Browser testing: 20 min
- Regression testing: 10 min
-
Deployment: 30 min
- Build verification: 10 min
- Deploy to test environment: 10 min
- Final verification: 10 min
-
Buffer: 30 min
Success Criteria
✅ Unauthorized users see ONLY the UnauthorizedComponent
✅ NO top app bar visible for unauthorized users
✅ NO chat FAB visible for unauthorized users
✅ NO project chooser dialog for unauthorized users
✅ Clean, professional unauthorized message
✅ No information leakage about application structure
✅ Authorized users experience no regression
✅ All existing functionality works correctly
References
- Issue #304
- Angular Material Documentation
- Firebase Auth Documentation
- OWASP Information Disclosure Guidelines
Approval
- Technical approach approved
- Security implications reviewed
- UX improvements verified
- Ready for implementation