Skip to content
Merged
4 changes: 4 additions & 0 deletions contract/battery.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ func Cases() []Case {
Notes: "Malformed cursor (camelCase key): InvalidArgument 'invalid after_cursor'."},
{Name: "tickets/list_unknown_param_ignored", Method: "GET", Path: "/api/v1/tickets?utterly_unknown=42", Auth: AuthReadTickets,
Notes: "Unknown query parameter ignored: identical payload to list_default."},
{Name: "tickets/list_modified_since_over_uint32", Method: "GET", Path: "/api/v1/tickets?modified_since=4294967296", Auth: AuthReadTickets,
Notes: "modifiedSince is uint32-backed: the handler rejects values above 4294967295 with 'value out of range', pinned here at the response level. Sent via the snake_case alias 'modified_since', which the spec validator treats as an ignored unknown param — so this does NOT exercise the request-level maximum; that bound is pinned by TestSpec_Uint32ParamsBounded (and enforced on the camelCase modifiedSince key by the request validator, cf. get_by_id_over_uint32)."},

// --- Tickets: get by id / ref ---------------------------------------
{Name: "tickets/get_by_id_happy", Method: "GET", Path: "/api/v1/tickets/42", Auth: AuthReadTickets,
Expand All @@ -179,6 +181,8 @@ func Cases() []Case {
Notes: "NotFound names the numeric id."},
{Name: "tickets/get_by_id_parse_error", Method: "GET", Path: "/api/v1/tickets/abc", Auth: AuthReadTickets,
Notes: "Non-numeric id under {ticket_id}: leaked parse-error text. Frozen."},
{Name: "tickets/get_by_id_over_uint32", Method: "GET", Path: "/api/v1/tickets/4294967296", Auth: AuthReadTickets,
Notes: "ticketId path param is uint32-backed: a value above 4294967295 leaks the parse 'value out of range'. Pins the spec's maximum: 4294967295 on the path param."},
{Name: "tickets/get_by_ref_happy", Method: "GET", Path: "/api/v1/tickets/ref/MF1UI9HE", Auth: AuthReadTickets,
Notes: "Ref binding returns the same GetTicketResponse shape."},
{Name: "tickets/get_by_ref_not_found", Method: "GET", Path: "/api/v1/tickets/ref/NOPE9999", Auth: AuthReadTickets,
Expand Down
16 changes: 16 additions & 0 deletions contract/goldens/tickets/get_by_id_over_uint32.golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"auth": "read:tickets",
"body": {
"code": 3,
"details": [],
"message": "type mismatch, parameter: ticket_id, error: strconv.ParseUint: parsing \"4294967296\": value out of range"
},
"case": "tickets/get_by_id_over_uint32",
"header": {
"Content-Type": "application/json"
},
"method": "GET",
"notes": "ticketId path param is uint32-backed: a value above 4294967295 leaks the parse 'value out of range'. Pins the spec's maximum: 4294967295 on the path param.",
"path": "/api/v1/tickets/4294967296",
"status": 400
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"auth": "read:tickets",
"body": {
"code": 3,
"details": [],
"message": "parsing field \"modified_since\": strconv.ParseUint: parsing \"4294967296\": value out of range"
},
"case": "tickets/list_modified_since_over_uint32",
"header": {
"Content-Type": "application/json"
},
"method": "GET",
"notes": "modifiedSince is uint32-backed: the handler rejects values above 4294967295 with 'value out of range', pinned here at the response level. Sent via the snake_case alias 'modified_since', which the spec validator treats as an ignored unknown param — so this does NOT exercise the request-level maximum; that bound is pinned by TestSpec_Uint32ParamsBounded (and enforced on the camelCase modifiedSince key by the request validator, cf. get_by_id_over_uint32).",
"path": "/api/v1/tickets?modified_since=4294967296",
"status": 400
}
76 changes: 75 additions & 1 deletion contract/spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,11 @@ var specViolatingRequests = map[string]mustFailRequest{
wantType: helpers.ParameterValidation,
wantMessage: "Path parameter 'ticketId' is not a valid integer",
},
"tickets/get_by_id_over_uint32": {
reason: "ticketId above 4294967295 violates the uint32 maximum on the path parameter",
wantType: helpers.ParameterValidation,
wantMessage: "Path parameter 'ticketId' failed to validate",
},
"tickets/messages_parse_error": {
reason: "non-numeric ticketId violates the integer path parameter",
wantType: helpers.ParameterValidation,
Expand Down Expand Up @@ -511,7 +516,7 @@ func TestSpec_CarveOutMapsAreLive(t *testing.T) {
assert.True(t, known[name], "templateUnmatchableCases key %q names no battery case", name)
}

assert.Len(t, specViolatingRequests, 9, "must-fail request entries are pinned")
assert.Len(t, specViolatingRequests, 10, "must-fail request entries are pinned")
for name := range specViolatingRequests {
assert.True(t, known[name], "specViolatingRequests key %q names no battery case", name)
}
Expand Down Expand Up @@ -1089,3 +1094,72 @@ func TestSpec_EveryGoldenRouteInSpec(t *testing.T) {
}
}
}

// uint32Max is the uint32 ceiling the request binder enforces
// (strconv.ParseUint(raw, 10, 32) in rest/query.go and rest/tickets.go): a
// value above it is rejected with a 400 "value out of range". The spec must
// advertise the same bound so it does not promise a range the API cannot
// accept.
const uint32Max = 4294967295

// TestSpec_Uint32ParamsBounded pins the uint32 upper bound on every
// integer-typed request parameter. Numeric query/path parameters are backed
// by uint32 proto fields (see proto/tickets.proto: ticket_id, per_page,
// modified_since, category_id, status_id, prefix_id, assigned_user_id,
// starter_user_id are all uint32); 64-bit ids serialize as decimal strings
// (type: string) and are not integer parameters. Each integer parameter
// schema — whether scalar or the items of an array parameter — must declare
// minimum: 0 AND maximum: 4294967295, so a value above the ceiling is a
// DOCUMENTED 400, not an undocumented one. Without the maximum the spec
// advertises an unbounded range the binder rejects.
func TestSpec_Uint32ParamsBounded(t *testing.T) {
_, model := loadSpec(t)

// checkIntegerSchema asserts s carries the uint32 bound when it is an
// integer schema; returns whether it was an integer schema.
checkIntegerSchema := func(where string, s *base.Schema) bool {
if s == nil || !slices.Contains(s.Type, "integer") {
return false
}
require.NotNil(t, s.Minimum, "%s: integer parameter declares no minimum", where)
assert.Equal(t, float64(0), *s.Minimum, "%s: uint32 parameter minimum must be 0", where)
require.NotNil(t, s.Maximum,
"%s: integer parameter declares no maximum — it is backed by uint32 and a value above %d is a 400 'value out of range'; declare maximum: %d",
where, uint32Max, uint32Max)
assert.Equal(t, float64(uint32Max), *s.Maximum,
"%s: uint32 parameter maximum must be %d", where, uint32Max)
return true
}

bounded := 0
for pair := orderedmap.First(model.Paths.PathItems); pair != nil; pair = pair.Next() {
route := pair.Key()
for method, op := range pair.Value().GetOperations().FromOldest() {
for _, p := range op.Parameters {
if p.Schema == nil || p.Schema.IsReference() {
continue
}
s := p.Schema.Schema()
if s == nil {
continue
}
where := fmt.Sprintf("%s %s param %q", strings.ToUpper(method), route, p.Name)
if checkIntegerSchema(where, s) {
bounded++
}
// Array parameters (the repeated-filter query keys) carry the
// integer in items.
if s.Items != nil && s.Items.IsA() && !s.Items.A.IsReference() {
if checkIntegerSchema(where+"[]", s.Items.A.Schema()) {
bounded++
}
}
}
}
}
// Non-vacuousness: the tickets surface alone has ticketId (×2), perPage
// (×2), modifiedSince, and the repeated uint32 filter items
// (categoryId, statusId, prefixId, assignedUserId, starterUserId).
assert.GreaterOrEqual(t, bounded, 10,
"expected the uint32 parameter set to be bounded, saw only %d", bounded)
}
26 changes: 22 additions & 4 deletions datastores/tickets.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,36 @@ var ErrInvalidCursor = errors.New("invalid cursor")
// Compile-time assertion: Mysql must implement referencecache.Loader.
var _ referencecache.Loader = (*Mysql)(nil)

// xf_phrase title prefixes for the three NF Tickets reference families.
//
// CAUTION — the add-on's phrase naming is ASYMMETRIC, not a typo below:
// status and priority titles carry NO `ticket_` infix
// (`nf_tickets_status.<id>`, `nf_tickets_priority.<id>`), but prefix titles
// DO (`nf_tickets_ticket_prefix.<id>`). This mirrors exactly what the NF
// Tickets add-on writes to xf_phrase in production; querying status/priority
// with the `ticket_` infix matches zero rows and silently warms an empty
// cache (every statusName/priorityName resolves to ""). Do not "normalise"
// these to a uniform shape — the inconsistency is in the source data, and
// the testdb fixtures (testdb/fixtures.sql) seed these exact forms.
const (
phrasePrefixStatus = "nf_tickets_status."
phrasePrefixPriority = "nf_tickets_priority."
phrasePrefixPrefix = "nf_tickets_ticket_prefix."
)

func (ds *Mysql) LoadStatusNames(ctx context.Context) (map[uint32]string, error) {
return ds.loadPhraseMap(ctx, "nf_tickets_ticket_status.")
return ds.loadPhraseMap(ctx, phrasePrefixStatus)
}
func (ds *Mysql) LoadPriorityNames(ctx context.Context) (map[uint32]string, error) {
return ds.loadPhraseMap(ctx, "nf_tickets_ticket_priority.")
return ds.loadPhraseMap(ctx, phrasePrefixPriority)
}
func (ds *Mysql) LoadPrefixNames(ctx context.Context) (map[uint32]string, error) {
return ds.loadPhraseMap(ctx, "nf_tickets_ticket_prefix.")
return ds.loadPhraseMap(ctx, phrasePrefixPrefix)
}

// loadPhraseMap reads rows from xf_phrase whose title starts with the given
// prefix (e.g. "nf_tickets_ticket_status."), parses the trailing integer id,
// prefix (one of the phrasePrefix* consts above, e.g. "nf_tickets_status."),
// parses the trailing integer id,
// and returns id -> phrase_text. Rows where the trailing part isn't a
// uint32 are skipped (not an error — the phrase table is shared, so
// unrelated rows can be in scope of the LIKE pattern at the edges).
Expand Down
25 changes: 23 additions & 2 deletions openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ info:
description: >-
Read-only access to 7Cav.us milpacs (military personnel records) and forum
tickets. All endpoints require a bearer API key.


Request leniency (frozen behavior, PRD #112): unknown query parameters are
ignored on every operation rather than rejected — a request carrying a
parameter this document does not declare is accepted, not a 400. This is
intentional and golden-pinned (e.g. roster/unknown_query_param_ignored,
tickets/list_unknown_param_ignored). Property-based tooling that flags such
a request as "accepted a schema-violating request" is observing expected
behavior; it is not a defect.
version: dev
externalDocs:
description: 7Cav API Repository
Expand Down Expand Up @@ -466,6 +475,7 @@ paths:
items:
type: integer
minimum: 0
maximum: 4294967295
- name: excludeSubcategories
in: query
required: false
Expand All @@ -486,6 +496,7 @@ paths:
items:
type: integer
minimum: 0
maximum: 4294967295
- name: prefixId
in: query
required: false
Expand All @@ -494,6 +505,7 @@ paths:
items:
type: integer
minimum: 0
maximum: 4294967295
- name: assignedUserId
in: query
required: false
Expand All @@ -502,6 +514,7 @@ paths:
items:
type: integer
minimum: 0
maximum: 4294967295
- name: starterUserId
in: query
required: false
Expand All @@ -510,12 +523,14 @@ paths:
items:
type: integer
minimum: 0
maximum: 4294967295
- name: modifiedSince
in: query
required: false
schema:
type: integer
minimum: 0
maximum: 4294967295
- name: includeHidden
in: query
required: false
Expand All @@ -527,14 +542,16 @@ paths:
schema:
type: integer
minimum: 0
maximum: 4294967295
- name: afterCursor
in: query
required: false
schema:
type: string
description: >-
Opaque cursor; empty string = first page. Round-trip the
nextCursor from the previous response.
nextCursor from the previous response. A malformed cursor (one
not produced by this API) is rejected with 400.
responses:
"200":
description: One page of tickets.
Expand Down Expand Up @@ -575,6 +592,7 @@ paths:
schema:
type: integer
minimum: 0
maximum: 4294967295
responses:
"200":
description: The ticket with its first messages.
Expand Down Expand Up @@ -651,20 +669,23 @@ paths:
schema:
type: integer
minimum: 0
maximum: 4294967295
- name: perPage
in: query
required: false
schema:
type: integer
minimum: 0
maximum: 4294967295
- name: afterCursor
in: query
required: false
schema:
type: string
description: >-
Opaque cursor; empty string = first page. Pagination is by
message position ascending.
message position ascending. A malformed cursor (one not produced
by this API) is rejected with 400.
- name: includeHidden
in: query
required: false
Expand Down
4 changes: 2 additions & 2 deletions rest/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func AuthMiddleware(ds datastores.Datastore, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := datastores.ParseBearerToken(r.Header.Get("Authorization"), maxTokenLen)
if token == "" {
Warn.Printf("Unauthorized HTTP access attempt (bad bearer scheme) from %s", r.RemoteAddr)
Warn.Printf("Unauthorized HTTP access attempt (bad bearer scheme) from %s (peer %s)", clientIP(r), r.RemoteAddr)
http.Error(w, errBearerScheme, http.StatusUnauthorized)
return
}
Expand All @@ -60,7 +60,7 @@ func AuthMiddleware(ds datastores.Datastore, next http.Handler) http.Handler {
// Zero rows: an unknown/expired key. The generic 401 tier —
// leaks nothing about whether a key exists, is expired, or
// lacks scopes (golden-pinned, #106).
Warn.Printf("Unauthorized HTTP access attempt from %s", r.RemoteAddr)
Warn.Printf("Unauthorized HTTP access attempt from %s (peer %s)", clientIP(r), r.RemoteAddr)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
Expand Down
63 changes: 63 additions & 0 deletions rest/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package rest
// stacks.

import (
"bytes"
"io"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -136,6 +137,68 @@ func TestAuthMiddleware_DatastoreError_Is503AndLogged(t *testing.T) {
assert.NotContains(t, logged, "cav7_secrettoken", "the bearer token must NEVER be logged")
}

// captureWarnLog redirects the package Warn logger into a buffer for one test
// and restores the previous writer afterwards — the 401 tiers Warn-log the
// rejected attempt (the only place a caller address is logged).
func captureWarnLog(t *testing.T) *bytes.Buffer {
t.Helper()
var buf bytes.Buffer
prev := Warn.Writer()
Warn.SetOutput(&buf)
t.Cleanup(func() { Warn.SetOutput(prev) })
return &buf
}

// The two 401 warn lines must report the resolved client IP and the socket
// peer — `from <client-ip> (peer <RemoteAddr>)` — each keeping its existing
// distinct prefix (#190, ADR 0005). With a trusted peer the client IP is
// resolved from the forwarding headers.

func TestAuthMiddleware_BadScheme401_LogsClientAndPeer(t *testing.T) {
withTrustedProxiesEnv(t, "10.0.0.0/8")
require.NoError(t, InitTrustedProxies())
buf := captureWarnLog(t)

ds := &fakeAuthDatastore{validateApiKey: func(string) (*datastores.ApiKeyResult, error) {
t.Fatal("ValidateApiKey must not be called for a scheme error")
return nil, nil
}}
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
req := httptest.NewRequest(http.MethodGet, "/api/v1/whatever", nil)
req.RemoteAddr = "10.0.0.5:443"
req.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.4")
rr := httptest.NewRecorder()
AuthMiddleware(ds, next).ServeHTTP(rr, req)

assert.Equal(t, http.StatusUnauthorized, rr.Code)
logged := buf.String()
assert.Contains(t, logged, "bad bearer scheme", "keeps its distinct prefix")
assert.Contains(t, logged, "from 203.0.113.9 (peer 10.0.0.5:443)")
}

func TestAuthMiddleware_UnknownKey401_LogsClientAndPeer(t *testing.T) {
withTrustedProxiesEnv(t, "10.0.0.0/8")
require.NoError(t, InitTrustedProxies())
buf := captureWarnLog(t)

ds := &fakeAuthDatastore{validateApiKey: func(string) (*datastores.ApiKeyResult, error) {
return nil, nil // zero rows → unknown key
}}
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
req := httptest.NewRequest(http.MethodGet, "/api/v1/whatever", nil)
req.RemoteAddr = "10.0.0.5:443"
req.Header.Set("Authorization", "Bearer cav7_unknownkey")
req.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.4")
rr := httptest.NewRecorder()
AuthMiddleware(ds, next).ServeHTTP(rr, req)

assert.Equal(t, http.StatusUnauthorized, rr.Code)
logged := buf.String()
// The unknown-key tier keeps its generic prefix (no "bad bearer scheme").
assert.NotContains(t, logged, "bad bearer scheme")
assert.Contains(t, logged, "from 203.0.113.9 (peer 10.0.0.5:443)")
}

// requireScope is the per-route authorization gate (ADR 0004: scope checks
// are per-handler; one scope never implies another).
func TestRequireScope(t *testing.T) {
Expand Down
Loading