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