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 @@
+
+