Files
mrambossekandClaude Opus 5 7a5004f293 server: a non-admin account can manage its own uploads
Signing in and being allowed to administer the server were the same
question: the OIDC callback refused a session outright to anyone outside
the admin group. A legitimate user could authenticate, be told what they
could not do, and be left with no way to see or delete the data their own
devices had uploaded.

They are separate questions now. Everyone who authenticates gets a
session; the admin flag rides inside the MAC'd payload, so promoting
yourself means forging a signature rather than editing a cookie, and a
role that does not parse fails closed to "user".

Pages scope themselves through visibleDevices/mayTouchRun rather than
filtering individually — per-page scoping is what the next page added
will be missing, and that failure is silent, since a listing that leaks
other people's uploads looks exactly like one that does not. Someone
else's run answers 404, not 403: a distinguishable refusal would confirm
the run exists. Revoking devices and minting enrolment tokens affect the
whole server and stay behind adminOnly at the route table, where someone
looking for who-may-do-what will actually find it.

Ownership is re-read per request instead of captured at sign-in, so
unlinking an account takes effect immediately rather than at session
expiry. Tests cover that, plus the degenerate case of an empty subject,
which must own nothing rather than everything with an empty account id.

Also: attribute the ICMPv6 finding per network. It compared "is IPv6
configured anywhere on this device" against "did any network answer",
which on a phone reports IPv6-is-broken about a network where IPv6 was
never configured. network_ref is null on every test, so the probe now
records per-network outcomes structurally rather than as prose a finding
would have to parse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:10:38 +02:00

249 lines
8.1 KiB
Go

// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package adminauth
import (
"encoding/base64"
"strconv"
"strings"
"testing"
"time"
)
// PBKDF2 at 600k iterations is slow on purpose, so these use a reduced count where the test is
// about logic rather than cost.
func fastCredential(t *testing.T, user, pass string) Credential {
t.Helper()
c, err := NewCredential(user, pass)
if err != nil {
t.Fatal(err)
}
return c
}
func TestVerifyAcceptsOnlyTheRightPair(t *testing.T) {
c := fastCredential(t, "admin", "correct-horse-battery")
if !c.Verify("admin", "correct-horse-battery") {
t.Fatal("the correct credentials were rejected")
}
for _, tc := range []struct{ user, pass string }{
{"admin", "wrong-password-here"},
{"admin", ""},
{"root", "correct-horse-battery"},
{"", "correct-horse-battery"},
{"ADMIN", "correct-horse-battery"}, // usernames are not case-folded
} {
if c.Verify(tc.user, tc.pass) {
t.Errorf("accepted %q/%q", tc.user, tc.pass)
}
}
}
// Two credentials with the same password must not share a hash, or one cracked password reveals
// every reuse of it and a precomputed table works against all of them.
func TestSaltsDiffer(t *testing.T) {
a := fastCredential(t, "admin", "the-same-password-x")
b := fastCredential(t, "admin", "the-same-password-x")
if a.Salt == b.Salt {
t.Fatal("two credentials share a salt")
}
if a.Hash == b.Hash {
t.Fatal("the same password produced the same hash twice")
}
// Both must still verify — a salt that is not actually used would also produce differing
// hashes if it were mixed in wrongly.
if !a.Verify("admin", "the-same-password-x") || !b.Verify("admin", "the-same-password-x") {
t.Fatal("a salted credential does not verify")
}
}
// The stored iteration count is used rather than the current constant, so raising the constant
// later does not silently lock out every existing password.
func TestOldIterationCountsStillVerify(t *testing.T) {
c := fastCredential(t, "admin", "a-perfectly-fine-pw")
c.Iterations = iterations // as stored
if !c.Verify("admin", "a-perfectly-fine-pw") {
t.Fatal("credential does not verify with its stored iteration count")
}
// A credential written before the field existed must not be treated as zero-iteration.
c.Iterations = 0
if !c.Verify("admin", "a-perfectly-fine-pw") {
t.Fatal("a credential with no recorded iteration count failed to verify")
}
}
func TestWeakInputsAreRefusedAtCreation(t *testing.T) {
if _, err := NewCredential("", "long-enough-password"); err == nil {
t.Error("an empty username was accepted")
}
if _, err := NewCredential("admin", "short"); err == nil {
t.Error("a short password was accepted")
}
}
func TestAnEmptyCredentialNeverVerifies(t *testing.T) {
var zero Credential
if zero.Verify("", "") {
t.Fatal("a server with no local admin configured accepted empty credentials")
}
if zero.Verify("admin", "anything") {
t.Fatal("an unset credential verified")
}
}
// ---- sessions ----------------------------------------------------------------------------
func TestSessionRoundTrip(t *testing.T) {
secret, _ := NewSecret()
s := NewSessions(secret, time.Hour)
got, err := s.Parse(s.Issue("local:admin", "Admin", true))
if err != nil {
t.Fatal(err)
}
if got.Subject != "local:admin" || got.Display != "Admin" {
t.Fatalf("session did not round-trip: %+v", got)
}
}
// The cookie carries its own contents, so the MAC is the only thing standing between a user and
// promoting themselves. Every tampered form must fail.
func TestTamperedSessionsAreRejected(t *testing.T) {
secret, _ := NewSecret()
s := NewSessions(secret, time.Hour)
good := s.Issue("local:admin", "Admin", true)
parts := strings.Split(good, ".")
tampered := []string{
"",
"garbage",
good + "x", // signature altered
strings.Replace(good, parts[0], "Zm9v", 1), // subject swapped
strings.Join(parts[:len(parts)-1], "."), // signature removed
parts[0] + "." + parts[1] + "." + parts[2], // signature removed, well-formed payload
}
for _, v := range tampered {
if _, err := s.Parse(v); err == nil {
t.Errorf("accepted a tampered session: %q", v)
}
}
}
func TestSessionsFromAnotherSecretAreRejected(t *testing.T) {
a, _ := NewSecret()
b, _ := NewSecret()
issued := NewSessions(a, time.Hour).Issue("local:admin", "Admin", true)
if _, err := NewSessions(b, time.Hour).Parse(issued); err == nil {
t.Fatal("a session signed with a different secret was accepted — rotating the secret " +
"must invalidate every existing session")
}
}
func TestExpiredSessionsAreRejected(t *testing.T) {
secret, _ := NewSecret()
// A negative TTL is not reachable through NewSessions, so issue with a real one and check
// the boundary via a session that has already run out.
s := NewSessions(secret, time.Millisecond)
v := s.Issue("local:admin", "Admin", true)
time.Sleep(10 * time.Millisecond)
if _, err := s.Parse(v); err == nil {
t.Fatal("an expired session was accepted")
}
}
// ---- throttle ------------------------------------------------------------------------------
func TestThrottleGrowsWithFailuresAndResetsOnSuccess(t *testing.T) {
tr := NewThrottle()
if d := tr.Delay(); d != 0 {
t.Fatalf("a first attempt was delayed by %v", d)
}
for i := 0; i < 2; i++ {
tr.Failed()
}
first := tr.Delay()
for i := 0; i < 6; i++ {
tr.Failed()
}
later := tr.Delay()
if !(later > first && first > 0) {
t.Fatalf("delay did not grow with failures: %v then %v", first, later)
}
tr.Succeeded()
if d := tr.Delay(); d != 0 {
t.Fatalf("a successful login did not clear the throttle: %v", d)
}
}
// A break-glass credential that an attacker can lock out is a denial of service against the one
// person who needs it. The delay must stay bounded rather than becoming a lockout.
func TestThrottleNeverLocksOutPermanently(t *testing.T) {
tr := NewThrottle()
for i := 0; i < 1000; i++ {
tr.Failed()
}
if d := tr.Delay(); d > 10*time.Second {
t.Fatalf("throttle became a lockout: %v", d)
}
}
func TestThrottleForgivesAfterAQuietPeriod(t *testing.T) {
tr := NewThrottle()
now := time.Now()
tr.now = func() time.Time { return now }
for i := 0; i < 10; i++ {
tr.Failed()
}
if tr.Delay() == 0 {
t.Fatal("failures did not register")
}
now = now.Add(2 * time.Minute)
if d := tr.Delay(); d != 0 {
t.Fatalf("an operator returning later was still throttled: %v", d)
}
}
// The admin flag is an authorisation decision carried in a cookie the client holds, so the
// interesting cases are all about what happens when the client lies about it.
func TestSessionAdminFlag(t *testing.T) {
s := NewSessions([]byte("secret"), time.Hour)
t.Run("round trips both ways", func(t *testing.T) {
admin, err := s.Parse(s.Issue("local:admin", "Admin", true))
if err != nil || !admin.Admin {
t.Fatalf("admin session did not survive: %+v err=%v", admin, err)
}
user, err := s.Parse(s.Issue("oidc#1", "Markus", false))
if err != nil || user.Admin {
t.Fatalf("user session came back as admin: %+v err=%v", user, err)
}
})
t.Run("promoting yourself invalidates the cookie", func(t *testing.T) {
// The whole point of putting the flag inside the MAC: editing it must break the signature
// rather than produce a valid admin session.
v := s.Issue("oidc#1", "Markus", false)
i := strings.LastIndex(v, ".")
tampered := strings.TrimSuffix(v[:i], ".u") + ".a" + v[i:]
if got, err := s.Parse(tampered); err == nil {
t.Fatalf("a self-promoted cookie was accepted as %+v", got)
}
})
t.Run("an unparseable role is not an admin", func(t *testing.T) {
// Fail closed: whatever a malformed privilege claim means, it does not mean "more access".
// Signed by us, so it passes the MAC — only the role parsing stands between it and admin.
exp := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10)
payload := base64.RawURLEncoding.EncodeToString([]byte("oidc#1")) + "." +
base64.RawURLEncoding.EncodeToString([]byte("Markus")) + "." + exp + ".ADMIN"
sess, err := s.Parse(payload + "." + s.mac(payload))
if err != nil {
t.Fatalf("unexpected parse error: %v", err)
}
if sess.Admin {
t.Fatal("a role of \"ADMIN\" was treated as the admin marker")
}
})
}