diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 000000000..2d29bf307 --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,4 @@ +## 2024-05-24 - [Missing Default Security Headers] +**Vulnerability:** Newly generated Amber framework apps omit foundational HTTP security headers (e.g., XSS Protection, Content-Type Options). +**Learning:** The pipeline configuration in `routes.cr.ecr` lacked a dedicated middleware pipe to enforce baseline security headers for web routes. +**Prevention:** Always include a SecureHeaders middleware in the default web pipeline to ensure a defense-in-depth posture out of the box. diff --git a/spec/amber/cli/commands/pipelines/pipelines_spec.cr b/spec/amber/cli/commands/pipelines/pipelines_spec.cr index 4b5e1c482..69a60f9c0 100644 --- a/spec/amber/cli/commands/pipelines/pipelines_spec.cr +++ b/spec/amber/cli/commands/pipelines/pipelines_spec.cr @@ -24,6 +24,7 @@ module Amber::CLI Amber::Pipe::Session Amber::Pipe::Flash Amber::Pipe::CSRF + Amber::Pipe::SecureHeaders ) static_default_plugs = %w( diff --git a/spec/amber/pipes/secure_headers_spec.cr b/spec/amber/pipes/secure_headers_spec.cr new file mode 100644 index 000000000..709bc363d --- /dev/null +++ b/spec/amber/pipes/secure_headers_spec.cr @@ -0,0 +1,35 @@ +require "../../spec_helper" + +module Amber::Pipe + describe SecureHeaders do + it "adds default security headers to the response (HSTS disabled by default)" do + request = HTTP::Request.new("GET", "/") + response = HTTP::Server::Response.new(IO::Memory.new) + context = HTTP::Server::Context.new(request, response) + + pipe = SecureHeaders.new + pipe.next = HTTP::Handler::HandlerProc.new { |ctx| } + + pipe.call(context) + + context.response.headers["X-Content-Type-Options"].should eq "nosniff" + context.response.headers["X-XSS-Protection"].should eq "1; mode=block" + context.response.headers["X-Frame-Options"].should eq "SAMEORIGIN" + context.response.headers["Referrer-Policy"].should eq "strict-origin-when-cross-origin" + context.response.headers.has_key?("Strict-Transport-Security").should be_false + end + + it "adds HSTS when enabled" do + request = HTTP::Request.new("GET", "/") + response = HTTP::Server::Response.new(IO::Memory.new) + context = HTTP::Server::Context.new(request, response) + + pipe = SecureHeaders.new(hsts: true) + pipe.next = HTTP::Handler::HandlerProc.new { |ctx| } + + pipe.call(context) + + context.response.headers["Strict-Transport-Security"].should eq "max-age=31536000; includeSubDomains" + end + end +end diff --git a/src/amber/cli/templates/app/config/routes.cr.ecr b/src/amber/cli/templates/app/config/routes.cr.ecr index f706e0630..c9efed1b5 100644 --- a/src/amber/cli/templates/app/config/routes.cr.ecr +++ b/src/amber/cli/templates/app/config/routes.cr.ecr @@ -10,6 +10,7 @@ Amber::Server.configure do plug Amber::Pipe::Session.new plug Amber::Pipe::Flash.new plug Amber::Pipe::CSRF.new + plug Amber::Pipe::SecureHeaders.new end pipeline :api do diff --git a/src/amber/pipes/secure_headers.cr b/src/amber/pipes/secure_headers.cr new file mode 100644 index 000000000..7acb20c1d --- /dev/null +++ b/src/amber/pipes/secure_headers.cr @@ -0,0 +1,35 @@ +module Amber + module Pipe + # The SecureHeaders pipe sets security-related HTTP headers to help + # protect against common vulnerabilities like XSS, clickjacking, and MIME sniffing. + class SecureHeaders < Base + property? hsts : Bool + + def initialize(@hsts = false) + end + + def call(context : HTTP::Server::Context) + headers = context.response.headers + + # Prevent browser from guessing the MIME type + headers["X-Content-Type-Options"] ||= "nosniff" + + # Prevent clickjacking by not allowing the page to be framed + headers["X-Frame-Options"] ||= "SAMEORIGIN" + + # Enable XSS filtering in browsers + headers["X-XSS-Protection"] ||= "1; mode=block" + + # Control how much referrer information is sent + headers["Referrer-Policy"] ||= "strict-origin-when-cross-origin" + + # Enforce HTTPS connections (if enabled) + if @hsts + headers["Strict-Transport-Security"] ||= "max-age=31536000; includeSubDomains" + end + + call_next(context) + end + end + end +end