Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Code: Firewall filter #416

Merged
merged 3 commits into from
Oct 28, 2021
Merged
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
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ uuid = { version = "0.8.2", default-features = false, features = ["v4"] }
thiserror = "1.0.30"
eyre = "0.6.5"
stable-eyre = "0.2.2"
ipnetwork = "0.18.0"

[target.'cfg(target_os = "linux")'.dependencies]
sys-info = "0.9.0"
Expand Down
1 change: 1 addition & 0 deletions build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
"proto/quilkin/extensions/filters/load_balancer/v1alpha1/load_balancer.proto",
"proto/quilkin/extensions/filters/local_rate_limit/v1alpha1/local_rate_limit.proto",
"proto/quilkin/extensions/filters/token_router/v1alpha1/token_router.proto",
"proto/quilkin/extensions/filters/firewall/v1alpha1/firewall.proto",
]
.iter()
.map(|name| std::env::current_dir().unwrap().join(name))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

syntax = "proto3";

package quilkin.extensions.filters.firewall.v1alpha1;

message Firewall {
enum Action {
Allow = 0;
Deny = 1;
}

message PortRange {
uint32 min = 1;
uint32 max = 2;
}

message Rule {
Action action = 1;
string source = 2;
repeated PortRange ports = 3;
}

repeated Rule on_read = 1;
repeated Rule on_write = 2;
}

1 change: 1 addition & 0 deletions src/filters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub mod capture_bytes;
pub mod compress;
pub mod concatenate_bytes;
pub mod debug;
pub mod firewall;
pub mod load_balancer;
pub mod local_rate_limit;
pub mod metadata;
Expand Down
222 changes: 222 additions & 0 deletions src/filters/firewall.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
/*
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

markmandel marked this conversation as resolved.
Show resolved Hide resolved
//! Filter for allowing/blocking traffic by IP and port.

use slog::{debug, o, Logger};

use crate::filters::firewall::metrics::Metrics;
use crate::filters::prelude::*;

use self::quilkin::extensions::filters::firewall::v1alpha1::Firewall as ProtoConfig;

crate::include_proto!("quilkin.extensions.filters.firewall.v1alpha1");

mod config;
XAMPPRocky marked this conversation as resolved.
Show resolved Hide resolved
mod metrics;

pub use config::{Action, Config, PortRange, PortRangeError, Rule};

pub const NAME: &str = "quilkin.extensions.filters.compress.v1alpha1.Firewall";

pub fn factory(base: &Logger) -> DynFilterFactory {
Box::from(FirewallFactory::new(base))
}

struct FirewallFactory {
log: Logger,
}

impl FirewallFactory {
pub fn new(base: &Logger) -> Self {
Self { log: base.clone() }
}
}

impl FilterFactory for FirewallFactory {
fn name(&self) -> &'static str {
NAME
}

fn create_filter(&self, args: CreateFilterArgs) -> Result<FilterInstance, Error> {
let (config_json, config) = self
.require_config(args.config)?
.deserialize::<Config, ProtoConfig>(self.name())?;

let filter = Firewall::new(&self.log, config, Metrics::new(&args.metrics_registry)?);
Ok(FilterInstance::new(
config_json,
Box::new(filter) as Box<dyn Filter>,
))
}
}

struct Firewall {
log: Logger,
metrics: Metrics,
on_read: Vec<Rule>,
on_write: Vec<Rule>,
}

impl Firewall {
fn new(base: &Logger, config: Config, metrics: Metrics) -> Self {
Self {
log: base.new(o!("source" => "extensions::Firewall")),
metrics,
on_read: config.on_read,
on_write: config.on_write,
}
}
}

impl Filter for Firewall {
fn read(&self, ctx: ReadContext) -> Option<ReadResponse> {
for rule in &self.on_read {
if rule.contains(ctx.from) {
return match rule.action {
Action::Allow => {
debug!(self.log, "Allow"; "event" => "read", "from" => ctx.from.to_string());
self.metrics.packets_allowed_read.inc();
Some(ctx.into())
}
Action::Deny => {
debug!(self.log, "Deny"; "event" => "read", "from" => ctx.from );
self.metrics.packets_denied_read.inc();
None
}
};
}
}

debug!(self.log, "default: Deny"; "event" => "read", "from" => ctx.from.to_string());
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So if no rules match, I default to not allowing a packet through. Is this acceptable?

I see a few options:

  1. This is fine
  2. If someone wants to allow all through by default, they can use an "everything" cidr range to do so.
  3. We add some sort of "default" allow/deny option to the config for on_read and on_write.

I'm happy to leave things as is for the time being, and see if anyone has a preference, but figured I would highlight this and ask the question.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the behaviour of other firewall projects here, what happens in Envoy, firewalld, iptables, etc? I think that will probably give us an answer.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thinking denying by default could be fine depending on what guarantees we're providing which we should mention in the docs - if we guarantee that we'll validate according to the rules list order then yeah it should be fine and they can have a catch all range at the end of the list.
If we don't guarantee the order rules are checked against (which would enable us to optimizations to e.g support when we have lots of rules and want to avoid scanning) then having an explicit 'default allow/deny' option might be preferrable.

Coming to think of it (probably not for this PR), we should also document the behavior when a user specifies conflicting port ranges in different rules e.g allow 10-20 in one rule and deny 20-30 in another - e.g we could treat the config as invalid

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm struggling to find an example with Envoy where it will do firewall filtering (I'm not sure it can? but it's entirely possible I'm lost in it's docs), but i did find it in Istio:
https://istio.io/latest/docs/reference/config/security/authorization-policy/ which has an interesting formula of decision making, but if nothing is applied, it does deny in the end (I personally think that checking and matching in the order configured is easier to understand, but maybe that's just me).

Authorization policy supports CUSTOM, DENY and ALLOW actions for access control. When CUSTOM, DENY and ALLOW actions are used for a workload at the same time, the CUSTOM action is evaluated first, then the DENY action, and finally the ALLOW action. The evaluation is determined by the following rules:

If there are any CUSTOM policies that match the request, evaluate and deny the request if the evaluation result is deny.
If there are any DENY policies that match the request, deny the request.
If there are no ALLOW policies for the workload, allow the request.
If any of the ALLOW policies match the request, allow the request.
Deny the request.

For iptables, the default is ACCEPT, but you can configure it to be DENY by default, there's a setting. I thought about having there be a top level default option, but wasn't sure if it was worth it? (or could be added later).

https://superuser.com/questions/437013/what-does-an-empty-iptables-mean
https://servercomputing.blogspot.com/2012/03/change-iptables-default-policy-to-drop.html

But definitely taking note that this needs to be spelling out very clearly in the docs, which will come once this PR is merged).

self.metrics.packets_denied_read.inc();
None
}

fn write(&self, ctx: WriteContext) -> Option<WriteResponse> {
for rule in &self.on_write {
if rule.contains(ctx.from) {
return match rule.action {
Action::Allow => {
debug!(self.log, "Allow"; "event" => "write", "from" => ctx.from.to_string());
self.metrics.packets_allowed_write.inc();
Some(ctx.into())
}
Action::Deny => {
debug!(self.log, "Deny"; "event" => "write", "from" => ctx.from );
self.metrics.packets_denied_write.inc();
None
}
};
}
}

debug!(self.log, "default: Deny"; "event" => "write", "from" => ctx.from.to_string());
self.metrics.packets_denied_write.inc();
None
}
}
#[cfg(test)]
mod tests {
use prometheus::Registry;
use std::net::Ipv4Addr;

use crate::endpoint::{Endpoint, Endpoints, UpstreamEndpoints};
use crate::filters::firewall::config::PortRange;
use crate::test_utils::logger;

use super::*;

#[test]
fn read() {
let firewall = Firewall {
log: logger(),
metrics: Metrics::new(&Registry::default()).unwrap(),
on_read: vec![Rule {
action: Action::Allow,
source: "192.168.75.0/24".parse().unwrap(),
ports: vec![PortRange::new(10, 100).unwrap()],
}],
on_write: vec![],
};

let local_ip = [192, 168, 75, 20];
let ctx = ReadContext::new(
UpstreamEndpoints::from(
Endpoints::new(vec![Endpoint::new((Ipv4Addr::LOCALHOST, 8080).into())]).unwrap(),
),
(local_ip, 80).into(),
vec![],
);
assert!(firewall.read(ctx).is_some());
assert_eq!(1, firewall.metrics.packets_allowed_read.get());
assert_eq!(0, firewall.metrics.packets_denied_read.get());

let ctx = ReadContext::new(
UpstreamEndpoints::from(
Endpoints::new(vec![Endpoint::new((Ipv4Addr::LOCALHOST, 8080).into())]).unwrap(),
),
(local_ip, 2000).into(),
vec![],
);
assert!(firewall.read(ctx).is_none());
assert_eq!(1, firewall.metrics.packets_allowed_read.get());
assert_eq!(1, firewall.metrics.packets_denied_read.get());

assert_eq!(0, firewall.metrics.packets_allowed_write.get());
assert_eq!(0, firewall.metrics.packets_denied_write.get());
}

#[test]
fn write() {
let firewall = Firewall {
log: logger(),
metrics: Metrics::new(&Registry::default()).unwrap(),
on_read: vec![],
on_write: vec![Rule {
action: Action::Allow,
source: "192.168.75.0/24".parse().unwrap(),
ports: vec![PortRange::new(10, 100).unwrap()],
}],
};

let endpoint = Endpoint::new((Ipv4Addr::LOCALHOST, 80).into());
let local_addr = (Ipv4Addr::LOCALHOST, 8081).into();

let ctx = WriteContext::new(
&endpoint,
([192, 168, 75, 20], 80).into(),
local_addr,
vec![],
);
assert!(firewall.write(ctx).is_some());
assert_eq!(1, firewall.metrics.packets_allowed_write.get());
assert_eq!(0, firewall.metrics.packets_denied_write.get());

let ctx = WriteContext::new(
&endpoint,
([192, 168, 77, 20], 80).into(),
local_addr,
vec![],
);
assert!(!firewall.write(ctx).is_some());
assert_eq!(1, firewall.metrics.packets_allowed_write.get());
assert_eq!(1, firewall.metrics.packets_denied_write.get());

assert_eq!(0, firewall.metrics.packets_allowed_read.get());
assert_eq!(0, firewall.metrics.packets_denied_read.get());
}
}
Loading