Verify RFC 9421 signatures on the Woodpecker config endpoint

This commit is contained in:
2026-08-06 07:24:47 +02:00
parent 57e47a15a0
commit 1781b62f07
2 changed files with 130 additions and 32 deletions
@@ -1,13 +1,17 @@
require "openssl" require "openssl"
require "base64" require "base64"
require "net/http" require "net/http"
require "digest"
module WarpEngine module WarpEngine
# Verifies the httpsig signature of Woodpecker configuration-extension # Verifies the signature of Woodpecker configuration-extension requests.
# requests (draft-cavage http-signatures, ed25519). The server sends the # Woodpecker 3.x signs with RFC 9421 HTTP message signatures (ed25519, via
# signed header list in the Signature header — typically "(request-target) date". # 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 class CiSignatureVerifier
SIGNATURE_PARAM = /(\w+)="([^"]*)"/ CAVAGE_PARAM = /(\w+)="([^"]*)"/
@key_cache = {} @key_cache = {}
@key_mutex = Mutex.new @key_mutex = Mutex.new
@@ -36,14 +40,12 @@ module WarpEngine
return false return false
end 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 = 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 rescue OpenSSL::PKey::PKeyError, ArgumentError => e
Rails.logger.error("[CiSignatureVerifier] #{e.class}: #{e.message}") Rails.logger.error("[CiSignatureVerifier] #{e.class}: #{e.message}")
false false
@@ -62,8 +64,70 @@ module WarpEngine
nil nil
end 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). # Parameters of the Signature header (or the "Authorization: Signature ..." form).
def signature_params def cavage_params
header = @request.headers["Signature"].presence header = @request.headers["Signature"].presence
if header.nil? if header.nil?
auth = @request.headers["Authorization"].to_s auth = @request.headers["Authorization"].to_s
@@ -71,10 +135,10 @@ module WarpEngine
end end
return nil if header.blank? return nil if header.blank?
header.scan(SIGNATURE_PARAM).to_h header.scan(CAVAGE_PARAM).to_h
end end
def build_signing_string(headers_list) def cavage_signing_string(headers_list)
lines = headers_list.split(" ").map do |name| lines = headers_list.split(" ").map do |name|
if name == "(request-target)" if name == "(request-target)"
"(request-target): #{@request.request_method.downcase} #{@request.fullpath}" "(request-target): #{@request.request_method.downcase} #{@request.fullpath}"
+52 -18
View File
@@ -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) allow(WarpEngine.config).to receive(:ci_extension_public_key).and_return(signing_key.public_to_pem)
end 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 date = Time.now.httpdate
signing_string = "(request-target): #{method} #{path}\ndate: #{date}" signing_string = "(request-target): #{method} #{path}\ndate: #{date}"
signature = Base64.strict_encode64(signing_key.sign(nil, signing_string)) 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 describe "POST /build/config" do
it "returns the rendered pipeline for a marker config" do it "returns the rendered pipeline for a marker config" do
post "/build/config", params: extension_payload("platform: godot\n"), payload = extension_payload("platform: godot\n")
headers: signed_headers post "/build/config", params: payload, headers: signed_headers(payload)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
configs = response.parsed_body["configs"] configs = response.parsed_body["configs"]
@@ -81,8 +100,8 @@ RSpec.describe "Build configs endpoint", type: :request do
end end
it "uses the marker's name override instead of the repo name" do it "uses the marker's name override instead of the repo name" do
post "/build/config", params: extension_payload("platform: godot\nname: othername\n"), payload = extension_payload("platform: godot\nname: othername\n")
headers: signed_headers 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"]).to include("othername")
expect(response.parsed_body["configs"].first["data"]).not_to include("mygame") 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" }, payload = { repo: { name: "mygame" },
configs: [ { name: ".woodpecker.yaml", data: "platform: godot\n" } ] }.to_json 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) expect(response).to have_http_status(:ok)
end end
it "returns 204 for a non-marker config" do it "returns 204 for a non-marker config" do
full_pipeline = "steps:\n - name: build\n image: alpine\n" payload = extension_payload("steps:\n - name: build\n image: alpine\n")
post "/build/config", params: payload, headers: signed_headers(payload)
post "/build/config", params: extension_payload(full_pipeline),
headers: signed_headers
expect(response).to have_http_status(:no_content) expect(response).to have_http_status(:no_content)
end end
it "returns 204 when no configuration is sent" do it "returns 204 when no configuration is sent" do
post "/build/config", params: { repo: { name: "mygame" } }.to_json, payload = { repo: { name: "mygame" } }.to_json
headers: signed_headers post "/build/config", params: payload, headers: signed_headers(payload)
expect(response).to have_http_status(:no_content) expect(response).to have_http_status(:no_content)
end end
it "returns 422 for a marker with an unknown platform" do it "returns 422 for a marker with an unknown platform" do
post "/build/config", params: extension_payload("platform: amiga\n"), payload = extension_payload("platform: amiga\n")
headers: signed_headers post "/build/config", params: payload, headers: signed_headers(payload)
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
end end
it "rejects a request with an invalid signature" do 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") other_key = OpenSSL::PKey.generate_key("ed25519")
allow(WarpEngine.config).to receive(:ci_extension_public_key).and_return(other_key.public_to_pem) 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) expect(response).to have_http_status(:forbidden)
end 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).and_return(nil)
allow(WarpEngine.config).to receive(:ci_extension_public_key_url).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"), payload = extension_payload("platform: godot\n")
headers: signed_headers post "/build/config", params: payload, headers: signed_headers(payload)
expect(response).to have_http_status(:forbidden) expect(response).to have_http_status(:forbidden)
end end