|
1
|
+ |
defmodule OpenAgents.Machines.ConstraintReachTest do
|
|
2
|
+ |
@moduledoc """
|
|
3
|
+ |
Issue #184 lists five PostgreSQL constraints on the computer tables with no
|
|
4
|
+ |
`check_constraint/2` or `unique_constraint/2` beside them, so a violation
|
|
5
|
+ |
arrives as a `Postgrex.Error` rather than an invalid changeset.
|
|
6
|
+ |
|
|
7
|
+ |
That is a defect when a user-supplied value can reach the constraint: a form
|
|
8
|
+ |
should have refused the input, and instead the request 500s. None of these
|
|
9
|
+ |
five can be reached that way, so each is recorded here rather than mapped —
|
|
10
|
+ |
and recorded as an assertion, because the reason it cannot be reached is a
|
|
11
|
+ |
property of the code that can change.
|
|
12
|
+ |
|
|
13
|
+ |
| Constraint | Guarded by |
|
|
14
|
+ |
| --- | --- |
|
|
15
|
+ |
| `machines_status_check` | `status` is not cast; writers pass literals |
|
|
16
|
+ |
| `machine_pairings_status_check` | same |
|
|
17
|
+ |
| `machines_token_expiry_after_creation` | `token_expires_at` is not cast, and its TTL is admitted at boot |
|
|
18
|
+ |
| `machines_token_digest_index` | 256 random bits, not user input |
|
|
19
|
+ |
| `machine_pairings_code_digest_index` | a random code, not user input |
|
|
20
|
+ |
|
|
21
|
+ |
The constraints stay. They are the last defense against a future writer, and
|
|
22
|
+ |
the tests below show each one still refusing a bad row when reached directly.
|
|
23
|
+ |
Mapping them would claim a population that cannot occur.
|
|
24
|
+ |
"""
|
|
25
|
+ |
|
|
26
|
+ |
use OpenAgents.DataCase, async: true
|
|
27
|
+ |
|
|
28
|
+ |
alias OpenAgents.Machines
|
|
29
|
+ |
alias OpenAgents.Machines.{Machine, Pairing}
|
|
30
|
+ |
alias OpenAgents.Repo
|
|
31
|
+ |
alias OpenAgents.RuntimeConfig
|
|
32
|
+ |
|
|
33
|
+ |
# Everything an attacker controls on the two unauthenticated or
|
|
34
|
+ |
# owner-facing write paths, plus every field the constraints guard.
|
|
35
|
+ |
@hostile %{
|
|
36
|
+ |
"name" => "box",
|
|
37
|
+ |
"tier" => "probe",
|
|
38
|
+ |
"status" => "compromised",
|
|
39
|
+ |
"token_digest" => "chosen",
|
|
40
|
+ |
"token_expires_at" => ~U[1970-01-01 00:00:00.000000Z],
|
|
41
|
+ |
"revoked_at" => ~U[1970-01-01 00:00:00.000000Z],
|
|
42
|
+ |
"code_digest" => "chosen",
|
|
43
|
+ |
"poll_secret_digest" => "chosen",
|
|
44
|
+ |
"expires_at" => ~U[1970-01-01 00:00:00.000000Z],
|
|
45
|
+ |
"user_id" => Ecto.UUID.generate(),
|
|
46
|
+ |
"machine_id" => Ecto.UUID.generate()
|
|
47
|
+ |
}
|
|
48
|
+ |
|
|
49
|
+ |
@guarded ~w(status token_digest token_expires_at code_digest expires_at)a
|
|
50
|
+ |
|
|
51
|
+ |
describe "no user-supplied value reaches the guarded columns" do
|
|
52
|
+ |
test "the computer changeset casts none of them" do
|
|
53
|
+ |
changes = Machine.create_changeset(%Machine{}, @hostile).changes
|
|
54
|
+ |
|
|
55
|
+ |
for field <- @guarded do
|
|
56
|
+ |
refute Map.has_key?(changes, field),
|
|
57
|
+ |
"#{field} became castable; it now reaches a constraint with no changeset mapping"
|
|
58
|
+ |
end
|
|
59
|
+ |
end
|
|
60
|
+ |
|
|
61
|
+ |
test "the pairing changeset casts none of them" do
|
|
62
|
+ |
changes = Pairing.create_changeset(%Pairing{}, @hostile).changes
|
|
63
|
+ |
|
|
64
|
+ |
for field <- @guarded do
|
|
65
|
+ |
refute Map.has_key?(changes, field),
|
|
66
|
+ |
"#{field} became castable; it now reaches a constraint with no changeset mapping"
|
|
67
|
+ |
end
|
|
68
|
+ |
end
|
|
69
|
+ |
|
|
70
|
+ |
test "a hostile pairing request still produces an ordinary pending row" do
|
|
71
|
+ |
# The unauthenticated entry point, carrying the whole hostile map.
|
|
72
|
+ |
assert {:ok, %{pairing: pairing}} = Machines.start_pairing(@hostile)
|
|
73
|
+ |
|
|
74
|
+ |
assert pairing.status == "pending"
|
|
75
|
+ |
assert pairing.code_digest != "chosen"
|
|
76
|
+ |
assert DateTime.compare(pairing.expires_at, DateTime.utc_now()) == :gt
|
|
77
|
+ |
end
|
|
78
|
+ |
end
|
|
79
|
+ |
|
|
80
|
+ |
describe "machines_token_expiry_after_creation is guarded before boot, not at insert" do
|
|
81
|
+ |
test "the admitted TTL range cannot produce a row the constraint refuses" do
|
|
82
|
+ |
settings = Application.get_all_env(:openagents)
|
|
83
|
+ |
|
|
84
|
+ |
for ttl <- [0, -1, -86_400] do
|
|
85
|
+ |
assert {:error, _reason} =
|
|
86
|
+ |
RuntimeConfig.validate(Keyword.put(settings, :machine_token_ttl_seconds, ttl)),
|
|
87
|
+ |
"a TTL of #{ttl} would make every pairing approval raise, and boot admitted it"
|
|
88
|
+ |
end
|
|
89
|
+ |
|
|
90
|
+ |
assert {:ok, _config} =
|
|
91
|
+ |
RuntimeConfig.validate(Keyword.put(settings, :machine_token_ttl_seconds, 300))
|
|
92
|
+ |
end
|
|
93
|
+ |
end
|
|
94
|
+ |
|
|
95
|
+ |
describe "the constraints are still load-bearing" do
|
|
96
|
+ |
test "machines_status_check refuses a status reached around the changeset" do
|
|
97
|
+ |
owner = owner()
|
|
98
|
+ |
|
|
99
|
+ |
assert_raise Postgrex.Error, ~r/machines_status_check/, fn ->
|
|
100
|
+ |
Repo.query!(
|
|
101
|
+ |
"""
|
|
102
|
+ |
INSERT INTO machines
|
|
103
|
+ |
(id, user_id, name, tier, roots, token_digest, token_expires_at, status,
|
|
104
|
+ |
scoped_forge_credentials_enabled, inserted_at, updated_at)
|
|
105
|
+ |
VALUES (gen_random_uuid(), $1, 'x', 'probe', '{}', 'd', NOW() + INTERVAL '1 day',
|
|
106
|
+ |
'compromised', false, NOW(), NOW())
|
|
107
|
+ |
""",
|
|
108
|
+ |
[Ecto.UUID.dump!(owner.id)]
|
|
109
|
+ |
)
|
|
110
|
+ |
end
|
|
111
|
+ |
end
|
|
112
|
+ |
|
|
113
|
+ |
test "machines_token_expiry_after_creation refuses an expiry before creation" do
|
|
114
|
+ |
owner = owner()
|
|
115
|
+ |
|
|
116
|
+ |
assert_raise Postgrex.Error, ~r/machines_token_expiry_after_creation/, fn ->
|
|
117
|
+ |
Repo.query!(
|
|
118
|
+ |
"""
|
|
119
|
+ |
INSERT INTO machines
|
|
120
|
+ |
(id, user_id, name, tier, roots, token_digest, token_expires_at, status,
|
|
121
|
+ |
scoped_forge_credentials_enabled, inserted_at, updated_at)
|
|
122
|
+ |
VALUES (gen_random_uuid(), $1, 'x', 'probe', '{}', 'd', NOW() - INTERVAL '1 day',
|
|
123
|
+ |
'active', false, NOW(), NOW())
|
|
124
|
+ |
""",
|
|
125
|
+ |
[Ecto.UUID.dump!(owner.id)]
|
|
126
|
+ |
)
|
|
127
|
+ |
end
|
|
128
|
+ |
end
|
|
129
|
+ |
|
|
130
|
+ |
test "machine_pairings_status_check refuses a status reached around the changeset" do
|
|
131
|
+ |
assert_raise Postgrex.Error, ~r/machine_pairings_status_check/, fn ->
|
|
132
|
+ |
Repo.query!(
|
|
133
|
+ |
"""
|
|
134
|
+ |
INSERT INTO machine_pairings
|
|
135
|
+ |
(id, code_digest, poll_secret_digest, name, tier, roots, status, expires_at,
|
|
136
|
+ |
inserted_at, updated_at)
|
|
137
|
+ |
VALUES (gen_random_uuid(), 'a', 'b', 'x', 'probe', '{}', 'compromised',
|
|
138
|
+ |
NOW() + INTERVAL '1 hour', NOW(), NOW())
|
|
139
|
+ |
""",
|
|
140
|
+ |
[]
|
|
141
|
+ |
)
|
|
142
|
+ |
end
|
|
143
|
+ |
end
|
|
144
|
+ |
|
|
145
|
+ |
test "machine_pairings_code_digest_index refuses a duplicate code" do
|
|
146
|
+ |
%{pairing: first} = start_pairing()
|
|
147
|
+ |
digest = Repo.get!(Pairing, first.id).code_digest
|
|
148
|
+ |
|
|
149
|
+ |
assert_raise Postgrex.Error, ~r/machine_pairings_code_digest_index/, fn ->
|
|
150
|
+ |
Repo.query!(
|
|
151
|
+ |
"""
|
|
152
|
+ |
INSERT INTO machine_pairings
|
|
153
|
+ |
(id, code_digest, poll_secret_digest, name, tier, roots, status, expires_at,
|
|
154
|
+ |
inserted_at, updated_at)
|
|
155
|
+ |
VALUES (gen_random_uuid(), $1, 'b', 'x', 'probe', '{}', 'pending',
|
|
156
|
+ |
NOW() + INTERVAL '1 hour', NOW(), NOW())
|
|
157
|
+ |
""",
|
|
158
|
+ |
[digest]
|
|
159
|
+ |
)
|
|
160
|
+ |
end
|
|
161
|
+ |
end
|
|
162
|
+ |
|
|
163
|
+ |
test "machines_token_digest_index refuses a duplicate computer token" do
|
|
164
|
+ |
owner = owner()
|
|
165
|
+ |
%{code: code} = start_pairing()
|
|
166
|
+ |
{:ok, machine} = Machines.approve_pairing(owner, code)
|
|
167
|
+ |
digest = Repo.get!(Machine, machine.id).token_digest
|
|
168
|
+ |
|
|
169
|
+ |
assert_raise Postgrex.Error, ~r/machines_token_digest_index/, fn ->
|
|
170
|
+ |
Repo.query!(
|
|
171
|
+ |
"""
|
|
172
|
+ |
INSERT INTO machines
|
|
173
|
+ |
(id, user_id, name, tier, roots, token_digest, token_expires_at, status,
|
|
174
|
+ |
scoped_forge_credentials_enabled, inserted_at, updated_at)
|
|
175
|
+ |
VALUES (gen_random_uuid(), $1, 'x', 'probe', '{}', $2, NOW() + INTERVAL '1 day',
|
|
176
|
+ |
'active', false, NOW(), NOW())
|
|
177
|
+ |
""",
|
|
178
|
+ |
[Ecto.UUID.dump!(owner.id), digest]
|
|
179
|
+ |
)
|
|
180
|
+ |
end
|
|
181
|
+ |
end
|
|
182
|
+ |
end
|
|
183
|
+ |
|
|
184
|
+ |
defp owner do
|
|
185
|
+ |
{:ok, user} =
|
|
186
|
+ |
OpenAgents.Accounts.upsert_github_user(%{
|
|
187
|
+ |
github_id: :erlang.phash2({__MODULE__, System.unique_integer()}),
|
|
188
|
+ |
github_login: "reach-#{System.unique_integer([:positive])}",
|
|
189
|
+ |
github_avatar_url: "https://avatars.githubusercontent.com/u/1?v=4"
|
|
190
|
+ |
})
|
|
191
|
+ |
|
|
192
|
+ |
user
|
|
193
|
+ |
end
|
|
194
|
+ |
|
|
195
|
+ |
defp start_pairing do
|
|
196
|
+ |
{:ok, started} = Machines.start_pairing(%{"name" => "box", "tier" => "probe"})
|
|
197
|
+ |
started
|
|
198
|
+ |
end
|
|
199
|
+ |
end
|