Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ and do not establish a presumption of conformity.
- Criteria with `check.type: resolve` request the product identifier
themselves (with the Accept header the criterion names) and need
`GET /api/v1/validate/<product identifier>`; with `POST` they are skipped.
`expect` checks status, content type and header fields (`exists`,
`equals`, `contains`, `matches`; field names case-insensitive, several
fields of one name combined with commas). `further_requests` are sent
afterwards with their own Accept header and evaluated independently. A
criterion fails if a check with severity error fails; if only checks with
`severity: warning` fail, the result is `warning`.
- Criteria with `check.type: did` send the DIDs of the passport to
[didlint](https://didlint.ownyourdata.eu) (DID Core, DID Resolution) and
check linked verifiable presentations for the VC Data Model 2.0 context. The
Expand Down
72 changes: 72 additions & 0 deletions app/services/header_assertion.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Evaluates the header assertions of an `expect` block (expect.headers) on the
# header fields of a response, as defined in CRITERIA-FORMAT.md of dpp-criteria:
#
# - `name` is matched case-insensitively; several fields with the same name are
# combined into one value, separated by ", " (RFC 9110 5.3);
# - `exists`: the field is present (true) or absent (false);
# - `equals`: the combined value equals the string exactly;
# - `contains`: the combined value, split at commas and trimmed, has a member
# equal to the string, compared case-insensitively ("Vary: *" therefore does
# not contain "Accept");
# - `matches`: the regular expression is found in the combined value;
# - a missing field fails every assertion except `exists: false`;
# - `severity: warning` reports a failure as a warning instead of a violation.
class HeaderAssertion
OPERATIONS = %w[exists equals contains matches].freeze

# assertions: the list under expect.headers; headers: Hash of field name =>
# value or list of values. Returns messages { severity:, message: }.
def self.messages(assertions, headers)
Array(assertions).flat_map do |assertion|
severity = assertion["severity"] == "warning" ? "warning" : "violation"
new(assertion).failures(headers).map { |m| { severity: severity, message: m } }
end
end

# The combined value of all fields named `name`, or nil if there is none.
def self.field(headers, name)
values = (headers || {}).select { |key, _| key.to_s.casecmp?(name.to_s) }.values.flatten.map(&:to_s)
values.empty? ? nil : values.join(", ")
end

def initialize(assertion)
@assertion = assertion
@name = assertion["name"].to_s
end

# Returns one message per operation that does not hold; empty means it holds.
def failures(headers)
value = self.class.field(headers, @name)
OPERATIONS.select { |op| @assertion.key?(op) }.filter_map { |op| failure(op, @assertion[op], value) }
end

private

def failure(op, expected, value)
return exists(expected, value) if op == "exists"
return "header #{@name} is missing, expected #{describe(op, expected)}" if value.nil?

holds = case op
when "equals" then value == expected.to_s
when "contains" then value.split(",").map(&:strip).any? { |member| member.casecmp?(expected.to_s) }
when "matches" then Regexp.new(expected.to_s).match?(value)
end
holds ? nil : "header #{@name} is #{value.inspect}, expected #{describe(op, expected)}"
rescue RegexpError => e
"regular expression #{expected.inspect} for header #{@name} is invalid (#{e.message})"
end

def exists(expected, value)
return nil if expected == !value.nil?

expected ? "header #{@name} is missing" : "header #{@name} is present (#{value.inspect}), expected it to be absent"
end

def describe(op, expected)
case op
when "equals" then "exactly #{expected.to_s.inspect}"
when "contains" then "a member #{expected.to_s.inspect}"
when "matches" then "a value matching /#{expected}/"
end
end
end
6 changes: 4 additions & 2 deletions app/services/http_resolver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@

# Plain HTTP(S) GET like a phone scanning a data carrier: no credentials,
# redirects followed, optional Accept header. Responses are cached per run.
# Header fields of the final response are kept as a Hash of lower-case field
# name => list of values (one entry per field line).
#
# Only public addresses are contacted: host names that resolve to loopback,
# private, link-local or other internal ranges are refused (also after a
# redirect), so that passports cannot make dpplint reach internal services.
# DPPLINT_ALLOW_PRIVATE_NETWORKS=1 lifts this for local development.
class HttpResolver
Response = Struct.new(:url, :status, :content_type, :body, :error, keyword_init: true) do
Response = Struct.new(:url, :status, :content_type, :body, :error, :headers, keyword_init: true) do
def media_type = content_type.to_s.split(";").first.to_s.strip.downcase
def success? = error.nil? && status.between?(200, 299)
end
Expand Down Expand Up @@ -80,7 +82,7 @@ def fetch(url, accept, mode)
next
end
body = mode == :get ? res.body : nil
return Response.new(url: uri.to_s, status: res.code.to_i, content_type: res["content-type"], body: body)
return Response.new(url: uri.to_s, status: res.code.to_i, content_type: res["content-type"], body: body, headers: res.to_hash)
end
Response.new(url: uri.to_s, error: "more than #{MAX_REDIRECTS} redirects")
rescue URI::InvalidURIError
Expand Down
11 changes: 9 additions & 2 deletions app/services/passport_linter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,13 @@ def resolve(base, check)
return base.merge(result: "skipped", reason: "rated by dpp-validator from its daily runs") if check["history"]
return base.merge(result: "skipped", reason: "needs a product identifier") if @product_id.blank?

messages = ResolveCheck.new(check, @product_id, @resolver).violations.map { |m| { severity: "violation", message: m } }
base.merge(result: messages.empty? ? "passed" : "failed", messages: messages)
resolve_check = ResolveCheck.new(check, @product_id, @resolver)
if (keys = resolve_check.unsupported).any?
return base.merge(result: "skipped", reason: "expect #{keys.join(', ')} is not evaluated for check type resolve in this version")
end

messages = resolve_check.messages
base.merge(result: result_for(messages), messages: messages)
end

def did(base, check, passport)
Expand Down Expand Up @@ -106,6 +111,8 @@ def links(base, check, passport)
base.merge(result: result_for(messages), messages: messages)
end

# failed if a check with severity error (a violation) fails, otherwise
# warning if a check with severity warning fails, otherwise passed.
def result_for(messages)
if messages.any? { |m| m[:severity] == "violation" } then "failed"
elsif messages.any? then "warning"
Expand Down
65 changes: 47 additions & 18 deletions app/services/resolve_check.rb
Original file line number Diff line number Diff line change
@@ -1,54 +1,83 @@
# Runs a criterion with check.type resolve against a product identifier.
#
# Without `expect`, the identifier has to resolve to a single passport object
# whose uniqueProductIdentifier equals the identifier. With `expect`, its
# status and content type are checked instead; an expected JSON content type
# also requires the body to be a JSON object.
# The first request follows the identifier with the Accept header of the
# criterion (`accept`). Without `expect`, it has to resolve to a single
# passport object whose uniqueProductIdentifier equals the identifier. With
# `expect`, its status, content type and header fields (`headers`, see
# HeaderAssertion) are checked instead; an expected JSON content type also
# requires the body to be a JSON object.
#
# `further_requests` are sent after the first request to the same identifier,
# each with its own `accept` and `expect`, and are evaluated independently of
# it. `severity: warning` on a further request reports every failure of that
# request as a warning.
class ResolveCheck
# Parts of `expect` this version evaluates for check type resolve.
SUPPORTED_EXPECT = %w[status content_type headers].freeze

def initialize(check, product_id, resolver)
@check = check
@product_id = product_id
@resolver = resolver
end

# Returns a list of violation messages; empty means passed.
def violations
res = @resolver.get(@product_id, accept: @check["accept"])
return [res.error] if res.error
# Keys of an `expect` block this version does not evaluate. A criterion that
# uses one is skipped rather than passed without that part.
def unsupported
expects = [@check["expect"], *Array(@check["further_requests"]).map { |r| r["expect"] }]
expects.compact.flat_map { |e| e.keys - SUPPORTED_EXPECT }.uniq
end

@check["expect"] ? expected(res) : resolves_to_passport(res)
# Returns messages { severity: "violation" | "warning", message: }; none means passed.
def messages
first = request(@check["accept"], @check["expect"])
further = Array(@check["further_requests"]).flat_map do |r|
request(r["accept"], r["expect"] || {}, severity: r["severity"], label: "request with Accept #{r['accept']}")
end
first + further
end

private

def expected(res)
expect = @check["expect"]
def request(accept, expect, severity: nil, label: nil)
res = @resolver.get(@product_id, accept: accept)
out = if res.error then [violation(res.error)]
elsif expect then expected(res, expect)
else resolves_to_passport(res)
end
out = out.map { |m| m.merge(severity: "warning") } if severity == "warning"
label ? out.map { |m| m.merge(message: "#{label}: #{m[:message]}") } : out
end

def expected(res, expect)
out = []
if expect["status"] && !expect["status"].include?(res.status)
out << "HTTP status is #{res.status}, expected #{expect['status'].join(' or ')}"
out << violation("HTTP status is #{res.status}, expected #{expect['status'].join(' or ')}")
end
if (type = expect["content_type"])
if res.media_type != type.downcase
out << "Content-Type is #{res.content_type.presence || 'missing'}, expected #{type}"
out << violation("Content-Type is #{res.content_type.presence || 'missing'}, expected #{type}")
elsif type.downcase == "application/json" && !json_object?(res.body)
out << "response is not a single JSON object"
out << violation("response is not a single JSON object")
end
end
out
out + HeaderAssertion.messages(expect["headers"], res.headers)
end

def resolves_to_passport(res)
return ["HTTP status is #{res.status}, expected 2xx"] unless res.success?
return [violation("HTTP status is #{res.status}, expected 2xx")] unless res.success?

json = JSON.parse(res.body) rescue nil
return ["response is not a single JSON object"] unless json.is_a?(Hash)
return [violation("response is not a single JSON object")] unless json.is_a?(Hash)

upi = json["uniqueProductIdentifier"]
return [] if upi == @product_id

["uniqueProductIdentifier is #{upi.inspect}, expected the requested identifier #{@product_id}"]
[violation("uniqueProductIdentifier is #{upi.inspect}, expected the requested identifier #{@product_id}")]
end

def violation(message) = { severity: "violation", message: message }

def json_object?(body)
JSON.parse(body).is_a?(Hash)
rescue JSON::ParserError
Expand Down
92 changes: 92 additions & 0 deletions test/services/header_assertion_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
require "test_helper"

class HeaderAssertionTest < ActiveSupport::TestCase
def problems(assertion, headers) = HeaderAssertion.new(assertion).failures(headers)
def holds?(assertion, headers) = problems(assertion, headers).empty?

VARY = { "vary" => ["Accept-Encoding, Accept"] }.freeze

test "exists true holds when the field is present, fails when it is missing" do
assert holds?({ "name" => "Vary", "exists" => true }, VARY)
assert_equal ["header Vary is missing"], problems({ "name" => "Vary", "exists" => true }, {})
end

test "exists false holds when the field is missing, fails when it is present" do
assert holds?({ "name" => "Vary", "exists" => false }, {})
assert_match(/header Vary is present .*expected it to be absent/, problems({ "name" => "Vary", "exists" => false }, VARY).first)
end

test "field present with an empty value exists" do
assert holds?({ "name" => "Vary", "exists" => true }, { "vary" => [""] })
end

test "equals compares the whole value exactly" do
assert holds?({ "name" => "Vary", "equals" => "Accept-Encoding, Accept" }, VARY)
refute holds?({ "name" => "Vary", "equals" => "Accept" }, VARY)
refute holds?({ "name" => "Vary", "equals" => "accept-encoding, accept" }, VARY)
end

test "contains finds a trimmed member, case-insensitively" do
assert holds?({ "name" => "Vary", "contains" => "Accept" }, VARY)
assert holds?({ "name" => "Vary", "contains" => "accept" }, { "vary" => ["Origin , ACCEPT "] })
assert_equal ['header Vary is "Accept-Encoding", expected a member "Accept"'],
problems({ "name" => "Vary", "contains" => "Accept" }, { "vary" => ["Accept-Encoding"] })
end

test "Vary * does not contain Accept" do
refute holds?({ "name" => "Vary", "contains" => "Accept" }, { "vary" => ["*"] })
end

test "matches searches the whole value" do
assert holds?({ "name" => "Cache-Control", "matches" => "max-age=\\d+" }, { "cache-control" => ["public, max-age=300"] })
refute holds?({ "name" => "Cache-Control", "matches" => "\\Ano-store\\z" }, { "cache-control" => ["public, no-store"] })
end

test "invalid regular expression fails with a message" do
assert_match(/regular expression "\(" for header X-Test is invalid/, problems({ "name" => "X-Test", "matches" => "(" }, { "x-test" => ["a"] }).first)
end

test "missing field fails equals, contains and matches" do
%w[equals contains matches].each do |op|
assert_match(/\Aheader Vary is missing, expected/, problems({ "name" => "Vary", op => "Accept" }, {}).first, op)
end
end

test "missing field fails every operation of an assertion except exists false" do
result = problems({ "name" => "Vary", "exists" => false, "contains" => "Accept" }, {})
assert_equal ['header Vary is missing, expected a member "Accept"'], result
end

test "field name is matched case-insensitively" do
assert holds?({ "name" => "vary", "contains" => "Accept" }, { "Vary" => ["Accept"] })
assert holds?({ "name" => "VARY", "equals" => "Accept" }, { "vary" => ["Accept"] })
end

test "several fields with the same name are combined with commas" do
headers = { "vary" => ["Origin", "Accept"] }
assert holds?({ "name" => "Vary", "equals" => "Origin, Accept" }, headers)
assert holds?({ "name" => "Vary", "contains" => "Accept" }, headers)
assert holds?({ "name" => "Vary", "matches" => "Origin, Accept" }, headers)
end

test "fields whose names differ only in case are combined" do
headers = { "Vary" => "Origin", "vary" => ["Accept"] }
assert_equal "Origin, Accept", HeaderAssertion.field(headers, "VARY")
end

test "all operations of one assertion are checked" do
result = problems({ "name" => "Vary", "exists" => true, "equals" => "Accept", "contains" => "Origin" }, { "vary" => ["Accept"] })
assert_equal ['header Vary is "Accept", expected a member "Origin"'], result
end

test "severity warning turns a failure into a warning" do
assertions = [{ "name" => "Vary", "contains" => "Accept", "severity" => "warning" }, { "name" => "Vary", "exists" => true }]
assert_equal [{ severity: "warning", message: "header Vary is missing, expected a member \"Accept\"" },
{ severity: "violation", message: "header Vary is missing" }],
HeaderAssertion.messages(assertions, {})
end

test "severity error is a violation" do
assert_equal ["violation"], HeaderAssertion.messages([{ "name" => "Vary", "exists" => true, "severity" => "error" }], nil).map { |m| m[:severity] }
end
end
Loading
Loading