balena-supervisor/test/integration/lib/firewall.spec.ts
Christina Ying Wang 9522c15ecd Change constants imports to remove 'require'
Signed-off-by: Christina Ying Wang <christina@balena.io>
2023-03-27 12:07:01 -07:00

532 lines
14 KiB
TypeScript

import * as _ from 'lodash';
import { expect } from 'chai';
import * as sinon from 'sinon';
import * as config from '~/src/config';
import * as logger from '~/src/logger';
import * as iptablesMock from '~/test-lib/mocked-iptables';
import * as dbFormat from '~/src/device-state/db-format';
import * as iptables from '~/lib/iptables';
import * as firewall from '~/lib/firewall';
import * as constants from '~/lib/constants';
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);
},
);
});
});
});