From c204df6a6d1f553ecd87ccf1f4fb2cd0e7442482 Mon Sep 17 00:00:00 2001 From: HARISHWAR-T Date: Fri, 12 Jun 2026 20:48:50 +0530 Subject: [PATCH] feat(security): add route-scoped frame-defense guard against clickjacking Add FrameBustingService (frame detection) and FrameDefenseGuard (CanActivate), wired onto the authenticated /calendar and /contacts routes so the app refuses to render those flows inside a frame. Add a conservative meta CSP (object-src 'none'; base-uri 'self') and a SECURITY.md documenting the authoritative frame-ancestors / X-Frame-Options response headers to set at the edge. Scoped per-route rather than globally so legitimate full-app embedding is not broken. Developed with assistance from Claude (Anthropic), per the AI-use policy in CONTRIBUTING.md; all code reviewed by the author. Co-Authored-By: Claude Opus 4.8 (1M context) --- SECURITY.md | 37 ++++++++++++++++ src/app/app.module.ts | 7 ++- .../security/frame-busting.service.spec.ts | 42 ++++++++++++++++++ src/app/security/frame-busting.service.ts | 44 +++++++++++++++++++ src/app/security/frame-defense.guard.spec.ts | 34 ++++++++++++++ src/app/security/frame-defense.guard.ts | 44 +++++++++++++++++++ src/index.html | 6 +++ 7 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 SECURITY.md create mode 100644 src/app/security/frame-busting.service.spec.ts create mode 100644 src/app/security/frame-busting.service.ts create mode 100644 src/app/security/frame-defense.guard.spec.ts create mode 100644 src/app/security/frame-defense.guard.ts diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..c75647848 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,37 @@ +# Security hardening notes + +## Clickjacking / frame defence + +If Runbox 7 is served without `Content-Security-Policy: frame-ancestors` and +`X-Frame-Options` response headers, a third-party page can load it in an +iframe and overlay it, tricking a logged-in user into clicking sensitive +controls they cannot see (clickjacking / UI redress). + +### Authoritative control — set these at the server / edge + + Content-Security-Policy: frame-ancestors 'none' # or 'self' + X-Frame-Options: DENY # legacy fallback + +`frame-ancestors` is the modern control; `X-Frame-Options` covers older +browsers. These MUST be response headers — `frame-ancestors` in a +`` tag is ignored by browsers. + +### Defence-in-depth — client-side guard (shipped) + +`FrameDefenseGuard` (Angular `CanActivate`, backed by `FrameBustingService`) +is applied to the authenticated `/calendar` and `/contacts` routes. When +`window.self !== window.top` it refuses to activate the route, so those flows +are not rendered inside a frame. + +It is intentionally scoped to those routes rather than applied globally +(e.g. via `APP_INITIALIZER`): a global break-out / `display:none` would break +any legitimate full-app embedding. Maintainers can extend +`canActivate: [FrameDefenseGuard]` to other sensitive routes as desired. This +client guard reduces risk when the headers above are absent but does not +replace them. + +### CSP via meta (shipped) + +`src/index.html` sets `object-src 'none'; base-uri 'self'` — both honoured in +`` and safe for this app. Tighter script/connect policies should be +delivered as tested response headers at the edge, not via meta. diff --git a/src/app/app.module.ts b/src/app/app.module.ts index bced66edb..83ed35759 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -88,6 +88,7 @@ import { SavedSearchesService } from './saved-searches/saved-searches.service'; import { HelpComponent } from './help/help.component'; import { HelpModule } from './help/help.module'; import { DomainRegisterRedirectComponent } from './domainregister/domreg-redirect.component'; +import { FrameDefenseGuard } from './security/frame-defense.guard'; window.addEventListener('dragover', (event) => event.preventDefault()); @@ -125,9 +126,11 @@ const routes: Routes = [ { path: 'help', component: HelpComponent }, { path: 'dev', loadChildren: () => import('./dev/dev.module').then(m => m.DevModule) }, { path: 'dkim', loadChildren: () => import('./dkim/dkim.module').then(m => m.DkimModule) }, - { path: 'calendar', loadChildren: () => import('./calendar-app/calendar-app.module').then(m => m.CalendarAppModule) }, + { path: 'calendar', canActivate: [FrameDefenseGuard], + loadChildren: () => import('./calendar-app/calendar-app.module').then(m => m.CalendarAppModule) }, { path: 'changelog', loadChildren: () => import('./changelog/changelog.module').then(m => m.ChangelogModule) }, - { path: 'contacts', loadChildren: () => import('./contacts-app/contacts-app.module').then(m => m.ContactsAppModule) }, + { path: 'contacts', canActivate: [FrameDefenseGuard], + loadChildren: () => import('./contacts-app/contacts-app.module').then(m => m.ContactsAppModule) }, { path: 'onscreen', loadChildren: () => import('./onscreen/onscreen.module').then(m => m.OnscreenModule) }, { path: 'identities', redirectTo: '/account/identities' }, { path: 'account-security', redirectTo: '/account/security' }, diff --git a/src/app/security/frame-busting.service.spec.ts b/src/app/security/frame-busting.service.spec.ts new file mode 100644 index 000000000..a75066549 --- /dev/null +++ b/src/app/security/frame-busting.service.spec.ts @@ -0,0 +1,42 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-2024 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { FrameBustingService } from './frame-busting.service'; + +describe('FrameBustingService', () => { + let service: FrameBustingService; + + beforeEach(() => { service = new FrameBustingService(); }); + + it('reports not framed when top === self', () => { + const w: any = {}; w.self = w; w.top = w; + expect(service.isFramed(w)).toBeFalse(); + }); + + it('reports framed when top !== self', () => { + const w: any = { top: {} }; w.self = w; + expect(service.isFramed(w)).toBeTrue(); + }); + + it('reports framed when accessing top throws (cross-origin)', () => { + const w: any = {}; w.self = w; + Object.defineProperty(w, 'top', { get() { throw new Error('cross-origin'); } }); + expect(service.isFramed(w)).toBeTrue(); + }); +}); diff --git a/src/app/security/frame-busting.service.ts b/src/app/security/frame-busting.service.ts new file mode 100644 index 000000000..da83c30e4 --- /dev/null +++ b/src/app/security/frame-busting.service.ts @@ -0,0 +1,44 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-2024 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { Injectable } from '@angular/core'; + +/** + * Detects whether the application is running inside a frame. Used by + * FrameDefenseGuard to refuse rendering sensitive routes when framed. + * + * This is defence-in-depth; the authoritative clickjacking control is the + * server sending `Content-Security-Policy: frame-ancestors` and + * `X-Frame-Options` (see SECURITY.md). + */ +@Injectable({ providedIn: 'root' }) +export class FrameBustingService { + /** + * @param win window-like object (injectable for testing) + * @returns true when the app is not the top-level window + */ + isFramed(win: Window = window): boolean { + try { + return win.self !== win.top; + } catch { + // A cross-origin parent makes win.top access throw — we are framed. + return true; + } + } +} diff --git a/src/app/security/frame-defense.guard.spec.ts b/src/app/security/frame-defense.guard.spec.ts new file mode 100644 index 000000000..08a060cf3 --- /dev/null +++ b/src/app/security/frame-defense.guard.spec.ts @@ -0,0 +1,34 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-2024 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { FrameDefenseGuard } from './frame-defense.guard'; + +describe('FrameDefenseGuard', () => { + function guardWith(framed: boolean): FrameDefenseGuard { + return new FrameDefenseGuard({ isFramed: () => framed } as any); + } + + it('allows activation when not framed (top === self)', () => { + expect(guardWith(false).canActivate()).toBeTrue(); + }); + + it('blocks activation when framed (top !== self)', () => { + expect(guardWith(true).canActivate()).toBeFalse(); + }); +}); diff --git a/src/app/security/frame-defense.guard.ts b/src/app/security/frame-defense.guard.ts new file mode 100644 index 000000000..42f521632 --- /dev/null +++ b/src/app/security/frame-defense.guard.ts @@ -0,0 +1,44 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-2024 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { Injectable } from '@angular/core'; +import { CanActivate } from '@angular/router'; +import { FrameBustingService } from './frame-busting.service'; + +/** + * Refuses to activate a route when the app is loaded inside a frame, + * mitigating clickjacking of the actions reachable from that route. Scoped + * per-route rather than applied globally so that any legitimate full-app + * embedding is not broken wholesale. + * + * Defence-in-depth only — the authoritative control is the frame-ancestors / + * X-Frame-Options response headers documented in SECURITY.md. + */ +@Injectable({ providedIn: 'root' }) +export class FrameDefenseGuard implements CanActivate { + constructor(private frameBusting: FrameBustingService) {} + + canActivate(): boolean { + if (this.frameBusting.isFramed()) { + console.warn('Runbox 7: refusing to render a sensitive route inside a frame (possible clickjacking).'); + return false; + } + return true; + } +} diff --git a/src/index.html b/src/index.html index dc5abc62d..a7a015be5 100644 --- a/src/index.html +++ b/src/index.html @@ -11,6 +11,12 @@ + +