From 7c9c8ff510644f4de41a325353b600b805f3fe8b Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Thu, 6 Aug 2026 07:24:47 +0200 Subject: [PATCH] Verify RFC 9421 signatures on the Woodpecker config endpoint --- .../warp_engine/ci_signature_verifier.rb | 92 ++++++++++++++++--- .../requests/build_configs_controller_spec.rb | 70 ++++++++++---- 2 files changed, 130 insertions(+), 32 deletions(-) diff --git a/libs/ruby/warp_engine/app/services/warp_engine/ci_signature_verifier.rb b/libs/ruby/warp_engine/app/services/warp_engine/ci_signature_verifier.rb index 451754b..edeb332 100644 --- a/libs/ruby/warp_engine/app/services/warp_engine/ci_signature_verifier.rb +++ b/libs/ruby/warp_engine/app/services/warp_engine/ci_signature_verifier.rb @@ -1,13 +1,17 @@ require "openssl" require "base64" require "net/http" +require "digest" module WarpEngine - # Verifies the httpsig signature of Woodpecker configuration-extension - # requests (draft-cavage http-signatures, ed25519). The server sends the - # signed header list in the Signature header — typically "(request-target) date". + # Verifies the signature of Woodpecker configuration-extension requests. + # Woodpecker 3.x signs with RFC 9421 HTTP message signatures (ed25519, via + # yaronf/httpsign): Signature-Input + Signature + Content-Digest headers, + # covered components "@request-target" and "content-digest". Older versions + # used draft-cavage http-signatures (a single Signature header) — kept as a + # fallback. class CiSignatureVerifier - SIGNATURE_PARAM = /(\w+)="([^"]*)"/ + CAVAGE_PARAM = /(\w+)="([^"]*)"/ @key_cache = {} @key_mutex = Mutex.new @@ -36,14 +40,12 @@ module WarpEngine return false end - params = signature_params - return false if params.nil? || params["signature"].blank? - - signing_string = build_signing_string(params.fetch("headers", "date")) - return false if signing_string.nil? - key = OpenSSL::PKey.read(pem) - key.verify(nil, Base64.decode64(params["signature"]), signing_string) + if @request.headers["Signature-Input"].present? + rfc9421_valid?(key) + else + cavage_valid?(key) + end rescue OpenSSL::PKey::PKeyError, ArgumentError => e Rails.logger.error("[CiSignatureVerifier] #{e.class}: #{e.message}") false @@ -62,8 +64,70 @@ module WarpEngine nil end + # --- RFC 9421 --- + + def rfc9421_valid?(key) + input = @request.headers["Signature-Input"].to_s + match = input.match(/\A\s*([\w.-]+)=(\(.*)\z/m) + return false if match.nil? + + label, inner = match[1], match[2] + components = inner[/\((.*?)\)/m, 1].to_s.scan(/"([^"]*)"/).flatten + return false if components.empty? + + signature = @request.headers["Signature"].to_s[/#{Regexp.escape(label)}=:([A-Za-z0-9+\/=]+):/, 1] + return false if signature.blank? + return false unless content_digest_valid?(components) + + lines = components.map do |component| + value = component_value(component) + return false if value.nil? + %("#{component}": #{value}) + end + lines << %("@signature-params": #{inner}) + + key.verify(nil, Base64.decode64(signature), lines.join("\n")) + end + + def component_value(name) + case name + when "@request-target" then @request.fullpath + when "@method" then @request.request_method + when "@target-uri" then @request.original_url + when "@authority" then @request.host_with_port + when "@path" then @request.path + when "@query" then "?#{@request.query_string}" + when /\A@/ then nil + else @request.headers[name] + end + end + + # When content-digest is a covered component, the body itself must match + # the digest header — this is what ties the signature to the payload. + def content_digest_valid?(components) + return true unless components.include?("content-digest") + + digest = @request.headers["Content-Digest"].to_s[/sha-256=:([A-Za-z0-9+\/=]+):/, 1] + return false if digest.blank? + + expected = Digest::SHA256.base64digest(@request.raw_post) + ActiveSupport::SecurityUtils.secure_compare(digest, expected) + end + + # --- draft-cavage fallback --- + + def cavage_valid?(key) + params = cavage_params + return false if params.nil? || params["signature"].blank? + + signing_string = cavage_signing_string(params.fetch("headers", "date")) + return false if signing_string.nil? + + key.verify(nil, Base64.decode64(params["signature"]), signing_string) + end + # Parameters of the Signature header (or the "Authorization: Signature ..." form). - def signature_params + def cavage_params header = @request.headers["Signature"].presence if header.nil? auth = @request.headers["Authorization"].to_s @@ -71,10 +135,10 @@ module WarpEngine end return nil if header.blank? - header.scan(SIGNATURE_PARAM).to_h + header.scan(CAVAGE_PARAM).to_h end - def build_signing_string(headers_list) + def cavage_signing_string(headers_list) lines = headers_list.split(" ").map do |name| if name == "(request-target)" "(request-target): #{@request.request_method.downcase} #{@request.fullpath}" diff --git a/libs/ruby/warp_engine/spec/requests/build_configs_controller_spec.rb b/libs/ruby/warp_engine/spec/requests/build_configs_controller_spec.rb index c8c8d9e..ac38105 100644 --- a/libs/ruby/warp_engine/spec/requests/build_configs_controller_spec.rb +++ b/libs/ruby/warp_engine/spec/requests/build_configs_controller_spec.rb @@ -15,7 +15,26 @@ RSpec.describe "Build configs endpoint", type: :request do allow(WarpEngine.config).to receive(:ci_extension_public_key).and_return(signing_key.public_to_pem) end - def signed_headers(method: "post", path: "/build/config") + # Woodpecker 3.x-style RFC 9421 signature over @request-target + content-digest. + def signed_headers(body, path: "/build/config", digest_body: nil) + digest = "sha-256=:#{Digest::SHA256.base64digest(digest_body || body)}:" + inner = %{("@request-target" "content-digest");created=#{Time.now.to_i};alg="ed25519"} + base = [ + %("@request-target": #{path}), + %("content-digest": #{digest}), + %("@signature-params": #{inner}) + ].join("\n") + signature = Base64.strict_encode64(signing_key.sign(nil, base)) + { + "Content-Digest" => digest, + "Signature-Input" => "woodpecker-ci-extensions=#{inner}", + "Signature" => "woodpecker-ci-extensions=:#{signature}:", + "Content-Type" => "application/json" + } + end + + # Legacy draft-cavage signature (single Signature header). + def cavage_signed_headers(method: "post", path: "/build/config") date = Time.now.httpdate signing_string = "(request-target): #{method} #{path}\ndate: #{date}" signature = Base64.strict_encode64(signing_key.sign(nil, signing_string)) @@ -68,8 +87,8 @@ RSpec.describe "Build configs endpoint", type: :request do describe "POST /build/config" do it "returns the rendered pipeline for a marker config" do - post "/build/config", params: extension_payload("platform: godot\n"), - headers: signed_headers + payload = extension_payload("platform: godot\n") + post "/build/config", params: payload, headers: signed_headers(payload) expect(response).to have_http_status(:ok) configs = response.parsed_body["configs"] @@ -81,8 +100,8 @@ RSpec.describe "Build configs endpoint", type: :request do end it "uses the marker's name override instead of the repo name" do - post "/build/config", params: extension_payload("platform: godot\nname: othername\n"), - headers: signed_headers + payload = extension_payload("platform: godot\nname: othername\n") + post "/build/config", params: payload, headers: signed_headers(payload) expect(response.parsed_body["configs"].first["data"]).to include("othername") expect(response.parsed_body["configs"].first["data"]).not_to include("mygame") @@ -92,40 +111,55 @@ RSpec.describe "Build configs endpoint", type: :request do payload = { repo: { name: "mygame" }, configs: [ { name: ".woodpecker.yaml", data: "platform: godot\n" } ] }.to_json - post "/build/config", params: payload, headers: signed_headers + post "/build/config", params: payload, headers: signed_headers(payload) + + expect(response).to have_http_status(:ok) + end + + it "accepts a legacy draft-cavage signed request" do + payload = extension_payload("platform: godot\n") + post "/build/config", params: payload, headers: cavage_signed_headers expect(response).to have_http_status(:ok) end it "returns 204 for a non-marker config" do - full_pipeline = "steps:\n - name: build\n image: alpine\n" - - post "/build/config", params: extension_payload(full_pipeline), - headers: signed_headers + payload = extension_payload("steps:\n - name: build\n image: alpine\n") + post "/build/config", params: payload, headers: signed_headers(payload) expect(response).to have_http_status(:no_content) end it "returns 204 when no configuration is sent" do - post "/build/config", params: { repo: { name: "mygame" } }.to_json, - headers: signed_headers + payload = { repo: { name: "mygame" } }.to_json + post "/build/config", params: payload, headers: signed_headers(payload) expect(response).to have_http_status(:no_content) end it "returns 422 for a marker with an unknown platform" do - post "/build/config", params: extension_payload("platform: amiga\n"), - headers: signed_headers + payload = extension_payload("platform: amiga\n") + post "/build/config", params: payload, headers: signed_headers(payload) expect(response).to have_http_status(:unprocessable_entity) end it "rejects a request with an invalid signature" do - headers = signed_headers + payload = extension_payload("platform: godot\n") + headers = signed_headers(payload) other_key = OpenSSL::PKey.generate_key("ed25519") allow(WarpEngine.config).to receive(:ci_extension_public_key).and_return(other_key.public_to_pem) - post "/build/config", params: extension_payload("platform: godot\n"), headers: headers + post "/build/config", params: payload, headers: headers + + expect(response).to have_http_status(:forbidden) + end + + it "rejects a request whose body does not match the signed content-digest" do + payload = extension_payload("platform: godot\n") + tampered = signed_headers(payload, digest_body: "something else") + + post "/build/config", params: payload, headers: tampered expect(response).to have_http_status(:forbidden) end @@ -141,8 +175,8 @@ RSpec.describe "Build configs endpoint", type: :request do allow(WarpEngine.config).to receive(:ci_extension_public_key).and_return(nil) allow(WarpEngine.config).to receive(:ci_extension_public_key_url).and_return(nil) - post "/build/config", params: extension_payload("platform: godot\n"), - headers: signed_headers + payload = extension_payload("platform: godot\n") + post "/build/config", params: payload, headers: signed_headers(payload) expect(response).to have_http_status(:forbidden) end