Verify RFC 9421 signatures on the Woodpecker config endpoint
This commit is contained in:
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user