Skip to content

Add Amber::Pipe::SecurityHeaders with a typed security_headers section - #1422

Draft
crimson-knight wants to merge 3 commits into
v2-devfrom
feature/v2-security-headers-pipe
Draft

crimson-knight wants to merge 3 commits into
v2-devfrom
feature/v2-security-headers-pipe

Conversation

@crimson-knight

Copy link
Copy Markdown
Member

What changed

Adds Amber::Pipe::SecurityHeaders, a new V2 pipe. It is designed from scratch and does not use the 1.x Amber::Pipe::SecureHeaders.

  • Headers. The pipe sends X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Content-Security-Policy: frame-ancestors 'none', Referrer-Policy: strict-origin-when-cross-origin, a conservative Permissions-Policy and Cross-Origin-Opener-Policy: same-origin. On HTTPS requests only, it also sends Strict-Transport-Security: max-age=63072000; includeSubDomains.
  • Write timing. The headers are written before call_next. A header already on the response is kept, so an earlier pipe keeps its value and a controller that assigns response.headers[...] has the final word. Error pages that Amber::Pipe::Error renders for a failing controller carry the headers.
  • Unrouted 404s. A request that matches no route never reaches a pipeline's pipes. Pipeline#call now gives that 404 the headers of the first plugged SecurityHeaders pipe. An app that plugs no SecurityHeaders pipe sees no change.
  • Typed settings. The values come from Amber::Configuration::SecurityHeadersConfig at security_headers on AppConfig, with a Settings#security_headers accessor. Every value has a secure default. An app overrides a value in config/environments/<env>.yml or with AMBER_SECURITY_HEADERS_*, and an empty value turns one header off. A value with a line break fails settings loading with ConfigurationError, and AppConfig#validate! reports it too.
  • HTTPS detection. HTTP::Request#https? now decides HTTPS in one place. A request counts as HTTPS when Amber terminates TLS itself (Settings#server_terminates_tls?, same name and body as Decide HTTPS and client IP through trusted proxies (server.trusted_proxies) #1415) or when it carries HTTPS: on. The cookie jar's Secure flag uses the same method, so HSTS and Secure cookies always agree.
  • Docs. Updated the routing guide (pipeline examples and the pipe table), the configuration guide (section, env table, HSTS includeSubDomains consequence, headers.add note, 404 coverage), the migration guide and CLAUDE.md.
  • CI. .github/workflows/ci.yml now pins actions/checkout and oprypin/install-crystal to full commit SHAs. The SHAs were resolved from the v4.4.0 and v1.9.4 tags. The lines match chore(v2): clear the 25 compiler warnings so the build is GREEN #1421, so either branch merges cleanly first. The machine's dep-pin push gate requires the pin.

Merge order with #1415

#1415 (feature/trusted-proxy-https) adds the same HTTP::Request#https? and Settings#server_terminates_tls? and changes the same line in cookies/store.cr. Resolve each conflict in favor of #1415. Its trusted-proxy rule then replaces this branch's interim rule, which trusts HTTPS: on from any peer, and the pipe and cookie jar follow it with no further change.

How it was verified

  • crystal-alpha build --no-codegen src/amber.cr: zero errors and zero warnings.
  • crystal-alpha spec: 2437 examples, 0 failures, 0 errors, 0 pending
  • crystal-alpha tool format --check src spec: clean.
  • Ameba (lib/ameba at the pinned commit cdd58b3) on all 15 changed Crystal files: no finding that the v2-dev baseline does not already have.
  • Build color: YELLOW. The spec compile prints the 25 deprecation warnings for Amber::Validators::Params#validation that v2-dev already prints. This branch adds none, and chore(v2): clear the 25 compiler warnings so the build is GREEN #1421 clears them.

New specs cover:

  • defaults
  • YAML overrides through the V2 loader
  • each env override
  • load-time rejection from YAML and from env
  • HSTS on HTTPS: on, on native TLS, off on plain HTTP and off when empty
  • agreement with the cookie jar's Secure decision
  • pre-set headers
  • a controller override
  • a routed response, an unrouted 404 (with and without the pipe plugged) and an Error-pipe 500

Codex review (gpt-6-sol, xhigh) and resolutions

Severity Finding Resolution
MAJOR https? trusted only the caller-supplied HTTPS: on header. Native TLS requests missed HSTS and Secure cookies, and any plain HTTP client could claim HTTPS. Fixed for native TLS: https? is true when Settings#server_terminates_tls?, with a spec. The trusted-peer boundary is #1415's server.trusted_proxies rule, which replaces this method on merge, so it is not duplicated here. Until then the HTTPS: on trust matches v2-dev's existing cookie-jar behavior. A spoofed header only gets HSTS sent back to the client that spoofed it.
MAJOR Unrouted 404s bypass every pipe, so they lacked the headers that the docs promised. Fixed: Pipeline#call applies the first plugged SecurityHeaders pipe's write_headers before rendering the 404. Specs cover the plugged and unplugged cases.
MAJOR Line-break validation ran only if the app called validate!. A bad value would turn every response into a 500 instead of failing the boot. Fixed: the V2 loader validates security_headers after the env overrides. Specs cover a bad value from YAML and from env.
MINOR Integration coverage was missing for 404s, error pages, native TLS and invalid loaded values, and headers.add was undocumented. Fixed: specs added for each, and the configuration guide documents headers.add. Trusted-proxy specs belong to #1415.
MINOR The HSTS default with includeSubDomains commits sibling subdomains to HTTPS without saying so. Fixed in the docs: the configuration guide states the consequence and gives the value without includeSubDomains. The secure default stays.

The full Codex output is in the plan folder as followup-37-unblocked-work/codex-amber-security-headers.md.

Crystal conventions rubric

# MAJOR item Status
1 Configuration in shard.yml n/a: shard.yml untouched. Its existing ai_docs: key is tracked by plan M-2b.
2 Configuration outside the library's API Fixed/compliant: a typed AppConfig section with the same pattern as StaticConfig/MailerConfig, EnvOverride and a Settings accessor
3 JSON perimeter n/a
4 not_nil! / .as(T) none added
5 Toolchain / color reported honestly crystal-alpha; YELLOW reported, cause above
6 Ruby-isms / stray output none
7 Log.setup, printing, ENV deep inside, shared mutable class vars none: ENV is read only in EnvOverride
8 AED naming list_of_headers, sends_strict_transport_security?, write_headers, first_plugged_security_headers
9 Bare rescue none
10 New behavior without specs specs added and run
11 Formatting crystal-alpha tool format --check clean
12 Lock / pins no dependency changes. Actions pinned by SHA.
13, 14, 15a, 15b Grant / jobs / replaced files n/a

The MINOR rubric items have no open defects in the new code.

Plan milestone

This serves M-1 (Amber prerequisites), "security-headers pipe added", in followup-34-mica-beryl-amber-v2-plan.md, section 7. It also supplies the headers that T7 (section 5) checks. Plugging the pipe into the generated browser and API pipelines is amber_cli template work and comes in a separate PR.

Add a V2 pipe that sends X-Content-Type-Options, X-Frame-Options, a
frame-ancestors Content-Security-Policy, Referrer-Policy, a conservative
Permissions-Policy, Cross-Origin-Opener-Policy, and, on HTTPS requests,
Strict-Transport-Security. It writes its headers before the rest of the
pipeline and never replaces one already on the response, so an earlier
pipe keeps its value and a controller has the final word.

Values come from Amber::Configuration::SecurityHeadersConfig, a new
security_headers section on AppConfig with secure defaults. Apps override
it in config/environments/<env>.yml or with AMBER_SECURITY_HEADERS_* env
variables; an empty value turns one header off. AppConfig#validate!
rejects values containing a line break.

HTTPS detection moves into HTTP::Request#https?, which the cookie jar's
Secure flag now uses as well, so Strict-Transport-Security and secure
cookies always agree. It keeps today's rule (HTTPS: on); the trusted-proxy
work in #1415 defines the same method and replaces its body.

Docs: routing guide pipelines and built-in pipe table, configuration guide
section and env override table, migration guide note and checklist item.
…eaders

- HTTP::Request#https? is also true when Amber terminates TLS itself
  (Settings#server_terminates_tls?, same name and body as PR #1415).
- Pipeline applies the first plugged SecurityHeaders pipe to the 404 for a
  request that matches no route, which never reaches a pipeline's pipes.
- The V2 loader validates security_headers after env overrides, so a value
  with a line break fails at boot instead of on every response.
- Specs for native TLS, unrouted 404, Error-pipe 500, and load-time
  rejection from YAML and env; Ameba heredoc indent in the config spec.
- Configuration guide: HSTS includeSubDomains consequence, headers.add,
  404 coverage.
actions/checkout v4.4.0 and oprypin/install-crystal v1.9.4, each resolved
from its tag to the commit SHA. Same lines as the pin on
chore/v2-clear-compiler-warnings, so either branch merges cleanly first.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant