findxpand 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +325 -0
- data/exe/findxpand +20 -0
- data/findxpand.gemspec +71 -0
- data/lib/findxpand/admin.rb +157 -0
- data/lib/findxpand/auto.rb +607 -0
- data/lib/findxpand/cli.rb +214 -0
- data/lib/findxpand/encoding.rb +160 -0
- data/lib/findxpand/middleware.rb +597 -0
- data/lib/findxpand/railtie.rb +72 -0
- data/lib/findxpand/rewrite.rb +628 -0
- data/lib/findxpand/signature.rb +127 -0
- data/lib/findxpand/store.rb +657 -0
- data/lib/findxpand.rb +255 -0
- metadata +62 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Is this manifest push signed by the holder of the token, and is it recent?
|
|
4
|
+
#
|
|
5
|
+
# A bearer token proves who sent a push and says nothing about what was in it.
|
|
6
|
+
# Anything holding the token — a logging proxy, a mirrored request, an old CI
|
|
7
|
+
# secret — could replay a captured manifest forever, or swap the body inside one,
|
|
8
|
+
# and a manifest is production content. So every push carries an HMAC-SHA256 over
|
|
9
|
+
# `timestamp.body`, with the timestamp inside the digest so a captured request
|
|
10
|
+
# expires rather than working indefinitely.
|
|
11
|
+
#
|
|
12
|
+
# The scheme is `engine.publish.notify`'s, unchanged and deliberately so: we
|
|
13
|
+
# already ship three verified implementations of it, and a second scheme here
|
|
14
|
+
# would be a second thing to get right. The secret is the middleware token rather
|
|
15
|
+
# than a new credential — whoever holds the token can already push, so a separate
|
|
16
|
+
# secret would add distribution and rotation for no gain.
|
|
17
|
+
#
|
|
18
|
+
# §3 rule 7: this is a write, so every branch here fails closed. The only
|
|
19
|
+
# question the module answers is *why* a push is unacceptable.
|
|
20
|
+
#
|
|
21
|
+
# Frozen string literals: on. The one accumulator is `String#+`, never `<<`.
|
|
22
|
+
|
|
23
|
+
require 'openssl'
|
|
24
|
+
|
|
25
|
+
module Findxpand
|
|
26
|
+
module Signature
|
|
27
|
+
# Mirrors `engine.fix.origin.SIGNATURE_HEADER` and `TIMESTAMP_HEADER`.
|
|
28
|
+
SIGNATURE_HEADER = 'x-findxpand-signature'
|
|
29
|
+
TIMESTAMP_HEADER = 'x-findxpand-timestamp'
|
|
30
|
+
|
|
31
|
+
# The same names as Rack presents them. A Rack server upcases a header,
|
|
32
|
+
# replaces the hyphens and prefixes `HTTP_`, so these are the keys actually
|
|
33
|
+
# read out of `env` — written out rather than computed at each call site so
|
|
34
|
+
# that a rename cannot leave one of the two spellings behind.
|
|
35
|
+
SIGNATURE_ENV = 'HTTP_X_FINDXPAND_SIGNATURE'
|
|
36
|
+
TIMESTAMP_ENV = 'HTTP_X_FINDXPAND_TIMESTAMP'
|
|
37
|
+
|
|
38
|
+
# How far out of date a push may be, in seconds. Mirrors
|
|
39
|
+
# `engine.fix.origin.TOLERANCE_S` and `sdk/next/src/webhook.ts`.
|
|
40
|
+
TOLERANCE_S = 300
|
|
41
|
+
|
|
42
|
+
# The reason this push is not acceptable, or `''` when it is.
|
|
43
|
+
#
|
|
44
|
+
# A reason rather than a boolean: "the signature does not match" and "the
|
|
45
|
+
# timestamp is forty minutes old" send an operator to completely different
|
|
46
|
+
# places — a wrong token versus a clock that has drifted — and a bare `false`
|
|
47
|
+
# makes them guess.
|
|
48
|
+
def self.problem(signature, timestamp, body, token, now: nil)
|
|
49
|
+
signature = signature.to_s
|
|
50
|
+
timestamp = timestamp.to_s
|
|
51
|
+
return 'unsigned manifest push' if signature.empty? || timestamp.empty?
|
|
52
|
+
|
|
53
|
+
begin
|
|
54
|
+
# `Float()`, never `String#to_f`. `'nonsense'.to_f` is `0.0` and raises
|
|
55
|
+
# nothing, so a garbled header would be read as the epoch and rejected
|
|
56
|
+
# as 1.7 billion seconds stale — a true refusal reported with a reason
|
|
57
|
+
# that sends an operator to look at a clock that is perfectly correct.
|
|
58
|
+
# `Float()` raises on anything that is not a number, which is the
|
|
59
|
+
# distinction this method exists to make.
|
|
60
|
+
age = ((now || Time.now.to_f) - Float(timestamp)).abs
|
|
61
|
+
rescue ArgumentError, TypeError
|
|
62
|
+
return 'manifest push carries an unreadable timestamp'
|
|
63
|
+
end
|
|
64
|
+
if age > TOLERANCE_S
|
|
65
|
+
return "manifest push is #{age.round}s out of date - replay, or a clock that has drifted"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
unless secure_compare(signature, expected(timestamp, body, token))
|
|
69
|
+
return 'manifest signature does not match'
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
''
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The digest we should have been sent, over the raw bytes.
|
|
76
|
+
#
|
|
77
|
+
# **Binary throughout, and that is not a detail.** `"#{timestamp}.#{body}"`
|
|
78
|
+
# is the obvious line and it raises `Encoding::CompatibilityError` the first
|
|
79
|
+
# time a manifest carries a non-ASCII byte: `rack.input` hands back an
|
|
80
|
+
# ASCII-8BIT String, the literal in the interpolation is UTF-8, and Ruby
|
|
81
|
+
# refuses to join the two once either holds a byte above 0x7F. Our market is
|
|
82
|
+
# Arabic (§14), so the first manifest with an Arabic title in it is the first
|
|
83
|
+
# push this would have rejected — with a 500 out of the middleware rather
|
|
84
|
+
# than a reason, since the raise happens before any verdict is reached.
|
|
85
|
+
# `String#b` gives a binary copy of each half and the concatenation is then
|
|
86
|
+
# always legal.
|
|
87
|
+
#
|
|
88
|
+
# Over the raw bytes, never over a re-serialised parse: re-encoding the JSON
|
|
89
|
+
# to check it would reorder keys and reject every legitimate push.
|
|
90
|
+
def self.expected(timestamp, body, token)
|
|
91
|
+
signed = "#{timestamp}.".b + body.to_s.b
|
|
92
|
+
OpenSSL::HMAC.hexdigest('SHA256', token.to_s, signed)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Constant-time comparison of two digests.
|
|
96
|
+
#
|
|
97
|
+
# `==` on Strings returns as soon as two bytes differ, so the time it takes
|
|
98
|
+
# is a function of how many leading characters an attacker guessed right —
|
|
99
|
+
# which is enough, over enough requests, to recover a signature byte by byte
|
|
100
|
+
# without ever knowing the token. The endpoint being compared against is a
|
|
101
|
+
# manifest push, so the prize is arbitrary content on the customer's pages.
|
|
102
|
+
#
|
|
103
|
+
# `OpenSSL.secure_compare` is preferred because it is the audited C
|
|
104
|
+
# implementation, and it hashes both inputs before comparing so the lengths
|
|
105
|
+
# cannot leak either. The fallback is the same construction in Ruby, for a
|
|
106
|
+
# build whose bundled openssl predates it: SHA-256 both sides so the loop
|
|
107
|
+
# length is fixed, then OR the XOR of every byte pair and test once at the
|
|
108
|
+
# end. `Rack::Utils.secure_compare` would do as well and is not used, because
|
|
109
|
+
# this gem declares no runtime dependency on Rack — it is a Rack middleware
|
|
110
|
+
# that needs nothing of Rack but the calling convention.
|
|
111
|
+
def self.secure_compare(given, expected)
|
|
112
|
+
given = given.to_s
|
|
113
|
+
expected = expected.to_s
|
|
114
|
+
return OpenSSL.secure_compare(given, expected) if OpenSSL.respond_to?(:secure_compare)
|
|
115
|
+
|
|
116
|
+
# `OpenSSL::Digest.digest(name, data)` rather than the
|
|
117
|
+
# `OpenSSL::Digest::SHA256` constant, which successive openssl releases
|
|
118
|
+
# have deprecated and un-deprecated. This branch exists for an old build;
|
|
119
|
+
# writing it against the newest spelling of the API would defeat it.
|
|
120
|
+
left = OpenSSL::Digest.digest('SHA256', given)
|
|
121
|
+
right = OpenSSL::Digest.digest('SHA256', expected)
|
|
122
|
+
difference = 0
|
|
123
|
+
left.bytes.each_with_index { |byte, index| difference |= byte ^ right.getbyte(index).to_i }
|
|
124
|
+
difference.zero?
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|