balena-supervisor/test/integration/lib/firewall.spec.ts
Christina Ying Wang 84a9e7e9ac Replace BALENA-FIREWALL rule in INPUT chain instead of flushing
The issue with the original Supervisor implementation of the firewall is that
on Supervisor start, the Supervisor flushes the INPUT chain of the filter table.
This doesn't play well with services that add to the INPUT chain on startup that
may start up before the Supervisor, such as certain NetworkManager connection
profiles. This change only replaces the BALENA-FIREWALL rule in the INPUT chain,
preserving the other rules as well as their order.

Closes: #1482
Change-type: patch
Signed-off-by: Christina Ying Wang <christina@balena.io>
2023-03-01 13:42:07 -08:00

533 lines
14 KiB
TypeScript

import _ = require('lodash');
import { expect } from 'chai';
import * as sinon from 'sinon';
import * as config from '~/src/config';
import * as firewall from '~/lib/firewall';
import * as logger from '~/src/logger';
import * as iptablesMock from '~/test-lib/mocked-iptables';
import * as dbFormat from '~/src/device-state/db-format';
import constants = require('~/lib/constants');
import * as iptables from '~/lib/iptables';
import { RuleAction, Rule } from '~/lib/iptables';
import { log } from '~/lib/supervisor-console';
describe('lib/firewall', function () {
let loggerSpy: sinon.SinonSpy;
let logSpy: sinon.SinonStub;
let apiEndpoint: string;
let listenPort: number;
before(async () => {
await config.initialized();
// spy the logs...
loggerSpy = sinon.spy(logger, 'logSystemMessage');
logSpy = log.error as sinon.SinonStub;
await firewall.initialised();
apiEndpoint = await config.get('apiEndpoint');
listenPort = await config.get('listenPort');
});
after(async () => {
loggerSpy.restore();
});
describe('Basic On/Off operation', () => {
it('should confirm the `changed` event is handled', async function () {
await iptablesMock.whilstMocked(async ({ hasAppliedRules }) => {
const changedSpy = sinon.spy();
config.on('change', changedSpy);
// set the firewall to be in off mode...
await config.set({ firewallMode: 'off' });
await hasAppliedRules;
// check it fired the events correctly...
expect(changedSpy.called).to.be.true;
expect(changedSpy.calledWith({ firewallMode: 'off' })).to.be.true;
});
});
it('should handle the HOST_FIREWALL_MODE configuration value: invalid', async function () {
await iptablesMock.whilstMocked(
async ({ hasAppliedRules, expectRule }) => {
// set the firewall to be in off mode...
await config.set({ firewallMode: 'invalid' });
await hasAppliedRules;
// expect that we jump to the firewall chain...
expectRule({
target: 'BALENA-FIREWALL',
chain: 'INPUT',
family: 4,
});
// expect to return...
expectRule({
chain: 'BALENA-FIREWALL',
target: 'RETURN',
family: 4,
});
},
);
});
it('should respect the HOST_FIREWALL_MODE configuration value: off', async function () {
await iptablesMock.whilstMocked(
async ({ hasAppliedRules, expectRule }) => {
// set the firewall to be in off mode...
await config.set({ firewallMode: 'off' });
await hasAppliedRules;
// expect that we jump to the firewall chain...
expectRule({
action: RuleAction.Insert,
target: 'BALENA-FIREWALL',
chain: 'INPUT',
family: 4,
});
// expect to return...
const returnRuleIdx = expectRule({
table: 'filter',
chain: 'BALENA-FIREWALL',
target: 'RETURN',
family: 4,
});
// ... just before we reject everything
const rejectRuleIdx = expectRule({
chain: 'BALENA-FIREWALL',
target: 'REJECT',
matches: iptablesMock.RuleProperty.NotSet,
family: 4,
});
expect(returnRuleIdx).to.be.lessThan(rejectRuleIdx);
},
);
});
it('should respect the HOST_FIREWALL_MODE configuration value: on', async function () {
await iptablesMock.whilstMocked(
async ({ hasAppliedRules, expectRule, expectNoRule }) => {
// set the firewall to be in auto mode...
await config.set({ firewallMode: 'on' });
await hasAppliedRules;
// expect that we jump to the firewall chain...
expectRule({
action: RuleAction.Insert,
target: 'BALENA-FIREWALL',
chain: 'INPUT',
family: 4,
});
// expect to not return for any reason...
expectNoRule({
chain: 'BALENA-FIREWALL',
target: 'RETURN',
});
},
);
});
it('should respect the HOST_FIREWALL_MODE configuration value: auto (no services in host-network)', async function () {
await iptablesMock.whilstMocked(
async ({ hasAppliedRules, expectRule }) => {
await dbFormat.setApps(
{
myapp: {
id: 2,
name: 'test-app2',
class: 'fleet',
is_host: false,
releases: {
abcdef2: {
id: 1232,
services: {
'test-service': {
id: 567,
image: 'test-image',
image_id: 5,
environment: {
TEST_VAR: 'test-string',
},
labels: {},
composition: {
tty: true,
},
},
},
networks: {},
volumes: {},
},
},
},
},
apiEndpoint,
);
// set the firewall to be in auto mode...
await config.set({ firewallMode: 'auto' });
await hasAppliedRules;
// expect that we jump to the firewall chain...
expectRule({
action: RuleAction.Insert,
target: 'BALENA-FIREWALL',
chain: 'INPUT',
family: 4,
});
// expect to return...
expectRule({
chain: 'BALENA-FIREWALL',
target: 'RETURN',
family: 4,
});
},
);
});
it('should respect the HOST_FIREWALL_MODE configuration value: auto (service in host-network)', async function () {
await iptablesMock.whilstMocked(
async ({ hasAppliedRules, expectRule, expectNoRule }) => {
await dbFormat.setApps(
{
myapp: {
id: 2,
name: 'test-app2',
class: 'fleet',
is_host: false,
releases: {
abcdef2: {
id: 1232,
services: {
'test-service': {
id: 567,
image: 'test-image',
image_id: 5,
environment: {
TEST_VAR: 'test-string',
},
labels: {},
composition: {
tty: true,
network_mode: 'host',
},
},
},
networks: {},
volumes: {},
},
},
},
},
apiEndpoint,
);
// set the firewall to be in auto mode...
await config.set({ firewallMode: 'auto' });
await hasAppliedRules;
// expect that we jump to the firewall chain...
expectRule({
action: RuleAction.Insert,
target: 'BALENA-FIREWALL',
chain: 'INPUT',
family: 4,
});
// expect to return...
expectNoRule({
chain: 'BALENA-FIREWALL',
target: 'RETURN',
family: 4,
});
},
);
});
it('should catch errors when rule changes fail', async () => {
await iptablesMock.whilstMocked(async ({ hasAppliedRules }) => {
// clear the spies...
loggerSpy.resetHistory();
logSpy.resetHistory();
// set the firewall to be in off mode...
await config.set({ firewallMode: 'off' });
await hasAppliedRules;
// should have caught the error and logged it
expect(logSpy.calledWith('Error applying firewall mode')).to.be.true;
expect(loggerSpy.called).to.be.true;
}, iptablesMock.realRuleAdaptor);
});
// TODO: The way that iptables & mocked-iptables are implemented does not
// translate to well-written, side-effect free tests. While this test passes,
// it's not able to check that BALENA-FIREWALL was inserted at the correct
// position in the INPUT rule chain.
// We should refactor the test setup for iptables to write rules to a file,
// and test whether that file can be applied successfully with `iptables-restore --test`.
it('should only replace the BALENA-FIREWALL rule from INPUT chain', async () => {
const acceptRuleOnPort = (port: number) => ({
action: RuleAction.Append,
proto: 'tcp',
matches: [`--dport ${port}`],
target: 'ACCEPT',
});
await iptablesMock.whilstMocked(
async ({ hasAppliedRules, expectRule }) => {
// clear the spies...
loggerSpy.resetHistory();
logSpy.resetHistory();
// Add some rules to INPUT: 3 unrelated rules,
// and BALENA-FIREWALL at position 4.
await iptables
.build()
.forTable('filter', (filter) =>
filter.forChain('INPUT', (chain) =>
chain
.addRule(acceptRuleOnPort(80))
.addRule(acceptRuleOnPort(443))
.addRule(acceptRuleOnPort(22))
.addRule({
action: RuleAction.Append,
target: 'BALENA-FIREWALL',
chain: 'INPUT',
}),
),
)
.apply(iptablesMock.fakeRuleAdaptor);
// set the firewall to be in off mode...
await config.set({ firewallMode: 'off' });
await hasAppliedRules;
// Unrelated rules should not have been flushed
expectRule(acceptRuleOnPort(80));
expectRule(acceptRuleOnPort(443));
expectRule(acceptRuleOnPort(22));
// TODO: this should be testable but isn't because of the reason above.
// expectRule({
// action: RuleAction.Insert,
// target: 'BALENA-FIREWALL',
// chain: 'INPUT',
// id: 4,
// });
},
iptablesMock.fakeRuleAdaptor,
);
});
});
describe('Service rules', () => {
const rejectAllRule = {
target: 'REJECT',
chain: 'BALENA-FIREWALL',
matches: iptablesMock.RuleProperty.NotSet,
};
const checkForRules = (
rules: Array<iptablesMock.Testable<Rule>> | iptablesMock.Testable<Rule>,
expectRule: (rule: iptablesMock.Testable<Rule>) => number,
) => {
rules = _.castArray(rules);
rules.forEach((rule) => {
const ruleIdx = expectRule(rule);
// make sure we reject AFTER the rule...
const rejectAllRuleIdx = expectRule(rejectAllRule);
expect(ruleIdx).is.lessThan(rejectAllRuleIdx);
});
};
it('should have a rule to allow DNS traffic from the balena0 interface', async () => {
await iptablesMock.whilstMocked(
async ({ hasAppliedRules, expectRule }) => {
// set the firewall to be on...
await config.set({ firewallMode: 'on' });
await hasAppliedRules;
[4, 6].forEach((family: 4 | 6) => {
// expect that we have a rule to allow DNS access...
checkForRules(
{
family,
target: 'ACCEPT',
chain: 'BALENA-FIREWALL',
proto: 'udp',
matches: ['--dport 53', '-i balena0'],
},
expectRule,
);
});
},
);
});
it('should have a rule to allow SSH traffic any interface', async () => {
await iptablesMock.whilstMocked(
async ({ hasAppliedRules, expectRule }) => {
// set the firewall to be on...
await config.set({ firewallMode: 'on' });
await hasAppliedRules;
[4, 6].forEach((family: 4 | 6) => {
// expect that we have a rule to allow SSH access...
checkForRules(
{
family,
target: 'ACCEPT',
chain: 'BALENA-FIREWALL',
proto: 'tcp',
matches: ['--dport 22222'],
},
expectRule,
);
});
},
);
});
it('should have a rule to allow Multicast traffic any interface', async () => {
await iptablesMock.whilstMocked(
async ({ hasAppliedRules, expectRule }) => {
// set the firewall to be on...
await config.set({ firewallMode: 'on' });
await hasAppliedRules;
[4, 6].forEach((family: 4 | 6) => {
// expect that we have a rule to allow multicast...
checkForRules(
{
family,
target: 'ACCEPT',
chain: 'BALENA-FIREWALL',
matches: ['-m addrtype', '--dst-type MULTICAST'],
},
expectRule,
);
});
},
);
});
it('should have a rule to allow balenaEngine traffic any interface', async () => {
await iptablesMock.whilstMocked(
async ({ hasAppliedRules, expectRule }) => {
// set the firewall to be on...
await config.set({ firewallMode: 'on' });
await hasAppliedRules;
[4, 6].forEach((family: 4 | 6) => {
// expect that we have a rule to allow balenaEngine access...
checkForRules(
{
family,
target: 'ACCEPT',
chain: 'BALENA-FIREWALL',
proto: 'tcp',
matches: ['--dport 2375'],
},
expectRule,
);
});
},
);
});
});
describe('Supervisor API access', () => {
it('should allow access in localmode', async function () {
await iptablesMock.whilstMocked(
async ({ hasAppliedRules, expectRule }) => {
// set the device to be in local mode...
await config.set({ localMode: true });
await hasAppliedRules;
[4, 6].forEach((family: 4 | 6) => {
// make sure we have a rule to allow traffic on ANY interface
const allowRuleIdx = expectRule({
proto: 'tcp',
matches: [`--dport ${listenPort}`],
target: 'ACCEPT',
chain: 'BALENA-FIREWALL',
table: 'filter',
family,
});
// make sure we have a rule to block traffic on ANY interface also
const rejectRuleIdx = expectRule({
proto: 'tcp',
matches: [`--dport ${listenPort}`],
target: 'REJECT',
chain: 'BALENA-FIREWALL',
table: 'filter',
family,
});
// we should always reject AFTER we allow
expect(allowRuleIdx).to.be.lessThan(rejectRuleIdx);
});
},
);
});
it('should allow limited access in non-localmode', async function () {
await iptablesMock.whilstMocked(
async ({ hasAppliedRules, expectRule, expectNoRule }) => {
// set the device to be in local mode...
await config.set({ localMode: false });
await hasAppliedRules;
// ensure we have no unrestricted rule...
expectNoRule({
chain: 'BALENA-FIREWALL',
proto: 'tcp',
matches: [`--dport ${listenPort}`],
target: 'ACCEPT',
family: 4,
});
// ensure we do have a restricted rule for each interface...
let allowRuleIdx = -1;
constants.allowedInterfaces.forEach((intf) => {
[4, 6].forEach((family: 4 | 6) => {
allowRuleIdx = expectRule({
chain: 'BALENA-FIREWALL',
proto: 'tcp',
matches: [`--dport ${listenPort}`, `-i ${intf}`],
target: 'ACCEPT',
family,
});
});
});
// make sure we have a rule to block traffic on ANY interface also
const rejectRuleIdx = expectRule({
proto: 'tcp',
matches: [`--dport ${listenPort}`],
target: 'REJECT',
chain: 'BALENA-FIREWALL',
table: 'filter',
});
// we should always reject AFTER we allow
expect(allowRuleIdx).to.be.lessThan(rejectRuleIdx);
},
);
});
});
});