Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -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
`<meta http-equiv>` 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
`<meta>` and safe for this app. Tighter script/connect policies should be
delivered as tested response headers at the edge, not via meta.
7 changes: 5 additions & 2 deletions src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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' },
Expand Down
42 changes: 42 additions & 0 deletions src/app/security/frame-busting.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
// ---------- 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();
});
});
44 changes: 44 additions & 0 deletions src/app/security/frame-busting.service.ts
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
// ---------- 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;
}
}
}
34 changes: 34 additions & 0 deletions src/app/security/frame-defense.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
// ---------- 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();
});
});
44 changes: 44 additions & 0 deletions src/app/security/frame-defense.guard.ts
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
// ---------- 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;
}
}
6 changes: 6 additions & 0 deletions src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@
<meta name="theme-color" content="#1976d2">

<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<!-- Conservative CSP delivered via meta. These two directives are honoured
in <meta> and do not constrain Angular's script/style/worker/connect
needs. NOTE: frame-ancestors is intentionally NOT set here — browsers
ignore frame-ancestors in <meta>; it MUST be a response header (see
SECURITY.md). -->
<meta http-equiv="Content-Security-Policy" content="object-src 'none'; base-uri 'self'" />
<meta name="description" content="Fast, secure, and sustainable email services provided by email professionals. Powerful Email, Domain, and Web Hosting for businesses." />
<meta name="keywords" content="email, email hosting, email address, web hosting, professional, secure, privacy, fast, webmail, mobile, iPhone, POP, SMTP, IMAP, filter, Outlook, Thunderbird" />
<meta name="copyright" content="Copyright &copy; 2016-2018 Runbox" />
Expand Down