feat(canary): token HTTP handlers (API + trigger routes)
Phase 9 task 9.6.
Routes:
RegisterAPIRoutes(r):
GET /tokens/types → static list of 7 type descriptors
POST /tokens → CreateToken (validation + service.Create)
RegisterTriggerRoutes(r):
GET /c/{id} → HandleTrigger (webbug, slowredirect,
docx, pdf, envfile use this path)
POST /c/{id}/fingerprint → HandleFingerprint (slowredirect)
HandleFunc /k/{id} → HandleTrigger (kubeconfig, bare path)
HandleFunc /k/{id}/* → HandleTrigger (kubeconfig wildcard,
any HTTP method)
HandleTrigger flow:
- extract token id from chi.URLParam
- service.GetByID (returns nil-nil for not-found)
- if token exists and !enabled, treat as unknown
- resolve generator (known token → its type; unknown + path
prefix /k/ → kubeconfig; unknown otherwise → webbug)
- generator.Trigger(ctx, tok, r) → event + response
- record event via EventRecorder interface if present (Phase 10
wires real event.Service)
- write response: headers + body OR 302 + Location for redirect
CreateToken flow:
- 64KB body cap via http.MaxBytesReader
- JSON decode (bad → 400 BAD_JSON)
- extract fingerprint + real IP via middleware helpers
- service.Create
- error switch: validation/unknown-type/destination/include-keys
map to 400; generator-failure to 500
- happy path: 201 with {token, artifact} envelope per spec §8.2
ArtifactJSON discriminator pattern:
- kind=url → url + destination_url
- kind=file → filename + content_type + content_b64 (base64)
- kind=text → filename + content_type + content (raw)
- kind=conn... → connection_string
Tests (~10 cases): 7-type list, happy path with envelope assertion,
bad JSON, validation failure, known-token records event + writes
response, unknown-token still returns shape (defense-in-depth),
disabled-token treated as unknown, fingerprint without recorder
returns 204, ArtifactJSON discriminator for all 4 kinds.
Phase 9 task 9.6 discharged. Wire-up + config + webbug refactor +
integration test next.
This commit is contained in:
parent
019bfda437
commit
0a456f97ae
|
|
@ -0,0 +1,323 @@
|
|||
// ©AngelaMos | 2026
|
||||
// handler.go
|
||||
|
||||
package token
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware"
|
||||
)
|
||||
|
||||
const (
|
||||
urlParamTokenID = "id"
|
||||
|
||||
headerContentType = "Content-Type"
|
||||
headerLocation = "Location"
|
||||
contentTypeJSON = "application/json"
|
||||
|
||||
errorCodeValidation = "VALIDATION_ERROR"
|
||||
errorCodeBadJSON = "BAD_JSON"
|
||||
errorCodeInternalError = "INTERNAL_ERROR"
|
||||
errorCodeUnknownType = "UNKNOWN_TYPE"
|
||||
errorCodeGenerateFailed = "GENERATE_FAILED"
|
||||
|
||||
respMessageValidation = "request validation failed"
|
||||
respMessageBadJSON = "invalid JSON body"
|
||||
respMessageInternalError = "internal server error"
|
||||
respMessageGenerateFailed = "artifact generation failed"
|
||||
respMessageUnknownType = "unknown token type"
|
||||
|
||||
kubeconfigPathPrefix = "/k/"
|
||||
|
||||
createTokenBodyMaxBytes = 64 * 1024
|
||||
fingerprintBodyMaxBytes = 64 * 1024
|
||||
)
|
||||
|
||||
type EventRecorder interface {
|
||||
Record(ctx context.Context, t *Token, evt *event.Event) error
|
||||
}
|
||||
|
||||
type FingerprintRecorder interface {
|
||||
AttachFingerprint(
|
||||
ctx context.Context,
|
||||
tokenID, sourceIP string,
|
||||
fingerprint json.RawMessage,
|
||||
) error
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
events EventRecorder
|
||||
fingerprintRecorder FingerprintRecorder
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewHandler(
|
||||
svc *Service,
|
||||
events EventRecorder,
|
||||
fingerprint FingerprintRecorder,
|
||||
logger *slog.Logger,
|
||||
) *Handler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Handler{
|
||||
svc: svc,
|
||||
events: events,
|
||||
fingerprintRecorder: fingerprint,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterAPIRoutes(r chi.Router) {
|
||||
r.Get("/tokens/types", h.GetTypes)
|
||||
r.Post("/tokens", h.CreateToken)
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterTriggerRoutes(r chi.Router) {
|
||||
r.Get("/c/{"+urlParamTokenID+"}", h.HandleTrigger)
|
||||
r.Post("/c/{"+urlParamTokenID+"}/fingerprint", h.HandleFingerprint)
|
||||
r.HandleFunc("/k/{"+urlParamTokenID+"}", h.HandleTrigger)
|
||||
r.HandleFunc("/k/{"+urlParamTokenID+"}/*", h.HandleTrigger)
|
||||
}
|
||||
|
||||
func (h *Handler) GetTypes(w http.ResponseWriter, _ *http.Request) {
|
||||
h.writeJSON(w, http.StatusOK, envelopeData(TypeDescriptors()))
|
||||
}
|
||||
|
||||
func (h *Handler) CreateToken(w http.ResponseWriter, r *http.Request) {
|
||||
limited := http.MaxBytesReader(w, r.Body, createTokenBodyMaxBytes)
|
||||
var req CreateRequest
|
||||
if err := json.NewDecoder(limited).Decode(&req); err != nil {
|
||||
h.writeJSON(w, http.StatusBadRequest, envelopeError(
|
||||
errorCodeBadJSON, respMessageBadJSON,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
fp := middleware.ExtractFingerprint(r)
|
||||
ip := middleware.RealIP(r)
|
||||
|
||||
tok, art, err := h.svc.Create(r.Context(), req, fp, ip)
|
||||
if err != nil {
|
||||
h.writeCreateError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := tok.ToResponse(
|
||||
h.svc.TriggerURL(tok.ID),
|
||||
h.svc.ManageURL(tok.ManageID),
|
||||
)
|
||||
h.writeJSON(w, http.StatusCreated, envelopeData(map[string]any{
|
||||
"token": resp,
|
||||
"artifact": artifactToJSON(art),
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *Handler) HandleTrigger(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, urlParamTokenID)
|
||||
if id == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
tok, err := h.svc.GetByID(r.Context(), id)
|
||||
if err != nil {
|
||||
h.logger.WarnContext(r.Context(), "trigger lookup failed",
|
||||
"token_id", id, "error", err)
|
||||
}
|
||||
if tok != nil && !tok.Enabled {
|
||||
tok = nil
|
||||
}
|
||||
|
||||
gen, ok := h.resolveGenerator(tok, r)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
evt, resp, gErr := gen.Trigger(r.Context(), tok, r)
|
||||
if gErr != nil {
|
||||
h.logger.WarnContext(r.Context(), "trigger generator failed",
|
||||
"error", gErr, "token_id", id)
|
||||
http.Error(w, respMessageInternalError, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if resp == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if tok != nil && evt != nil && h.events != nil {
|
||||
if recErr := h.events.Record(r.Context(), tok, evt); recErr != nil {
|
||||
h.logger.WarnContext(r.Context(), "record event failed",
|
||||
"error", recErr, "token_id", id)
|
||||
}
|
||||
}
|
||||
|
||||
h.writeTriggerResponse(w, r, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleFingerprint(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, urlParamTokenID)
|
||||
if id == "" || h.fingerprintRecorder == nil {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
limited := http.MaxBytesReader(w, r.Body, fingerprintBodyMaxBytes)
|
||||
var raw json.RawMessage
|
||||
if err := json.NewDecoder(limited).Decode(&raw); err != nil {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
if recErr := h.fingerprintRecorder.AttachFingerprint(
|
||||
r.Context(),
|
||||
id,
|
||||
middleware.RealIP(r),
|
||||
raw,
|
||||
); recErr != nil {
|
||||
h.logger.WarnContext(r.Context(), "attach fingerprint failed",
|
||||
"token_id", id, "error", recErr)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) resolveGenerator(
|
||||
tok *Token,
|
||||
r *http.Request,
|
||||
) (Generator, bool) {
|
||||
if tok != nil {
|
||||
return h.svc.Generator(tok.Type)
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, kubeconfigPathPrefix) {
|
||||
return h.svc.Generator(TypeKubeconfig)
|
||||
}
|
||||
return h.svc.Generator(TypeWebbug)
|
||||
}
|
||||
|
||||
func (h *Handler) writeCreateError(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
err error,
|
||||
) {
|
||||
switch {
|
||||
case errors.Is(err, ErrUnknownGeneratorType):
|
||||
h.writeJSON(w, http.StatusBadRequest, envelopeError(
|
||||
errorCodeUnknownType, respMessageUnknownType,
|
||||
))
|
||||
case errors.Is(err, ErrInvalidDestinationURL),
|
||||
errors.Is(err, ErrInvalidIncludeKeys):
|
||||
h.writeJSON(w, http.StatusBadRequest, envelopeError(
|
||||
errorCodeValidation, err.Error(),
|
||||
))
|
||||
case errors.Is(err, ErrGenerateFailed):
|
||||
h.logger.ErrorContext(r.Context(), "create token: generator",
|
||||
"error", err)
|
||||
h.writeJSON(w, http.StatusInternalServerError, envelopeError(
|
||||
errorCodeGenerateFailed, respMessageGenerateFailed,
|
||||
))
|
||||
case strings.Contains(err.Error(), "validate request"):
|
||||
h.writeJSON(w, http.StatusBadRequest, envelopeError(
|
||||
errorCodeValidation, respMessageValidation,
|
||||
))
|
||||
default:
|
||||
h.logger.ErrorContext(r.Context(), "create token", "error", err)
|
||||
h.writeJSON(w, http.StatusInternalServerError, envelopeError(
|
||||
errorCodeInternalError, respMessageInternalError,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) writeJSON(
|
||||
w http.ResponseWriter,
|
||||
status int,
|
||||
body any,
|
||||
) {
|
||||
w.Header().Set(headerContentType, contentTypeJSON)
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||
h.logger.Warn("write json response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) writeTriggerResponse(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
resp *TriggerResponse,
|
||||
) {
|
||||
for k, v := range resp.ExtraHeaders {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
if resp.ContentType != "" {
|
||||
w.Header().Set(headerContentType, resp.ContentType)
|
||||
}
|
||||
if resp.RedirectURL != "" {
|
||||
w.Header().Set(headerLocation, resp.RedirectURL)
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
if len(resp.Body) > 0 {
|
||||
if _, err := w.Write(resp.Body); err != nil {
|
||||
h.logger.WarnContext(r.Context(), "write trigger body",
|
||||
"error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func envelopeData(data any) map[string]any {
|
||||
return map[string]any{"success": true, "data": data}
|
||||
}
|
||||
|
||||
func envelopeError(code, message string) map[string]any {
|
||||
return map[string]any{
|
||||
"success": false,
|
||||
"error": map[string]any{
|
||||
"code": code,
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type ArtifactJSON struct {
|
||||
Kind string `json:"kind"`
|
||||
URL string `json:"url,omitempty"`
|
||||
DestinationURL string `json:"destination_url,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
ContentB64 string `json:"content_b64,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ConnectionString string `json:"connection_string,omitempty"`
|
||||
}
|
||||
|
||||
func artifactToJSON(a Artifact) ArtifactJSON {
|
||||
out := ArtifactJSON{Kind: string(a.Kind)}
|
||||
switch a.Kind {
|
||||
case KindURL:
|
||||
out.URL = a.URL
|
||||
out.DestinationURL = a.DestinationURL
|
||||
case KindFile:
|
||||
out.Filename = a.Filename
|
||||
out.ContentType = a.ContentType
|
||||
out.ContentB64 = base64.StdEncoding.EncodeToString(a.Content)
|
||||
case KindText:
|
||||
out.Filename = a.Filename
|
||||
out.ContentType = a.ContentType
|
||||
out.Content = string(a.Content)
|
||||
case KindConnectionString:
|
||||
out.ConnectionString = a.ConnectionString
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -0,0 +1,371 @@
|
|||
// ©AngelaMos | 2026
|
||||
// handler_test.go
|
||||
|
||||
package token_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators"
|
||||
)
|
||||
|
||||
type triggerGen struct {
|
||||
tokenType token.Type
|
||||
artifact generators.Artifact
|
||||
resp *generators.TriggerResponse
|
||||
evt *event.Event
|
||||
}
|
||||
|
||||
func (g *triggerGen) Type() token.Type { return g.tokenType }
|
||||
|
||||
func (g *triggerGen) Generate(
|
||||
_ context.Context,
|
||||
_ *token.Token,
|
||||
_ string,
|
||||
) (generators.Artifact, error) {
|
||||
return g.artifact, nil
|
||||
}
|
||||
|
||||
func (g *triggerGen) Trigger(
|
||||
_ context.Context,
|
||||
_ *token.Token,
|
||||
_ *http.Request,
|
||||
) (*event.Event, *generators.TriggerResponse, error) {
|
||||
return g.evt, g.resp, nil
|
||||
}
|
||||
|
||||
type recordingEvents struct {
|
||||
events []*event.Event
|
||||
}
|
||||
|
||||
func (r *recordingEvents) Record(
|
||||
_ context.Context,
|
||||
_ *token.Token,
|
||||
e *event.Event,
|
||||
) error {
|
||||
r.events = append(r.events, e)
|
||||
return nil
|
||||
}
|
||||
|
||||
func quietHandlerLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func newWebbugHandler(
|
||||
t *testing.T,
|
||||
gen token.Generator,
|
||||
) (*token.Handler, *fakeRepo, *recordingEvents) {
|
||||
t.Helper()
|
||||
repo := newFakeRepo()
|
||||
rec := &recordingEvents{}
|
||||
svc := token.NewService(
|
||||
repo,
|
||||
token.MapRegistry{token.TypeWebbug: gen},
|
||||
token.ServiceConfig{
|
||||
BaseURL: "https://canary.example.com",
|
||||
ManageURL: "https://canary.example.com",
|
||||
},
|
||||
)
|
||||
return token.NewHandler(svc, rec, nil, quietHandlerLogger()), repo, rec
|
||||
}
|
||||
|
||||
func TestGetTypes_Returns7Types(t *testing.T) {
|
||||
svc := token.NewService(newFakeRepo(), token.MapRegistry{},
|
||||
token.ServiceConfig{BaseURL: "https://x.test"})
|
||||
h := token.NewHandler(svc, nil, nil, quietHandlerLogger())
|
||||
|
||||
r := chi.NewRouter()
|
||||
h.RegisterAPIRoutes(r)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/tokens/types", nil))
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var body struct {
|
||||
Success bool `json:"success"`
|
||||
Data []token.TypeDescriptor `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(w.Body).Decode(&body))
|
||||
require.True(t, body.Success)
|
||||
require.Len(t, body.Data, 7)
|
||||
}
|
||||
|
||||
func TestCreateToken_HappyPath(t *testing.T) {
|
||||
gen := &triggerGen{
|
||||
tokenType: token.TypeWebbug,
|
||||
artifact: generators.Artifact{
|
||||
Kind: generators.KindURL, URL: "https://canary.example.com/c/x",
|
||||
},
|
||||
}
|
||||
h, _, _ := newWebbugHandler(t, gen)
|
||||
|
||||
r := chi.NewRouter()
|
||||
h.RegisterAPIRoutes(r)
|
||||
|
||||
body := strings.NewReader(`{
|
||||
"type":"webbug","memo":"m","alert_channel":"webhook",
|
||||
"webhook_url":"https://example.com/h","cf_turnstile_response":"t",
|
||||
"metadata":{}
|
||||
}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/tokens", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Token token.Response `json:"token"`
|
||||
Artifact token.ArtifactJSON `json:"artifact"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
|
||||
require.True(t, resp.Success)
|
||||
require.Equal(t, token.TypeWebbug, resp.Data.Token.Type)
|
||||
require.NotEmpty(t, resp.Data.Token.ID)
|
||||
require.Contains(t, resp.Data.Token.TriggerURL, "/c/")
|
||||
require.Contains(t, resp.Data.Token.ManageURL, "/m/")
|
||||
require.Equal(t, "url", resp.Data.Artifact.Kind)
|
||||
}
|
||||
|
||||
func TestCreateToken_BadJSON(t *testing.T) {
|
||||
gen := &triggerGen{tokenType: token.TypeWebbug}
|
||||
h, _, _ := newWebbugHandler(t, gen)
|
||||
|
||||
r := chi.NewRouter()
|
||||
h.RegisterAPIRoutes(r)
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/tokens",
|
||||
strings.NewReader(`{not json`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
require.Contains(t, w.Body.String(), "BAD_JSON")
|
||||
}
|
||||
|
||||
func TestCreateToken_ValidationFailure(t *testing.T) {
|
||||
gen := &triggerGen{tokenType: token.TypeWebbug}
|
||||
h, _, _ := newWebbugHandler(t, gen)
|
||||
|
||||
r := chi.NewRouter()
|
||||
h.RegisterAPIRoutes(r)
|
||||
|
||||
body := strings.NewReader(
|
||||
`{"type":"webbug","memo":"m","cf_turnstile_response":"t"}`,
|
||||
)
|
||||
req := httptest.NewRequest(http.MethodPost, "/tokens", body)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
require.Contains(t, w.Body.String(), "VALIDATION_ERROR")
|
||||
}
|
||||
|
||||
func TestHandleTrigger_KnownTokenReturnsResponseAndRecordsEvent(t *testing.T) {
|
||||
gen := &triggerGen{
|
||||
tokenType: token.TypeWebbug,
|
||||
artifact: generators.Artifact{Kind: generators.KindURL},
|
||||
resp: &generators.TriggerResponse{
|
||||
StatusCode: 200,
|
||||
ContentType: "image/gif",
|
||||
Body: []byte{0x47, 0x49, 0x46, 0x38},
|
||||
},
|
||||
evt: &event.Event{SourceIP: "1.2.3.4"},
|
||||
}
|
||||
h, repo, rec := newWebbugHandler(t, gen)
|
||||
|
||||
tok := &token.Token{
|
||||
ID: "abcdef012345", ManageID: "m", Type: token.TypeWebbug,
|
||||
AlertChannel: token.ChannelWebhook, Enabled: true,
|
||||
Metadata: json.RawMessage(`{}`),
|
||||
}
|
||||
require.NoError(t, repo.Insert(context.Background(), tok))
|
||||
|
||||
r := chi.NewRouter()
|
||||
h.RegisterTriggerRoutes(r)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/c/abcdef012345", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Equal(t, "image/gif", w.Header().Get("Content-Type"))
|
||||
require.Equal(t, []byte{0x47, 0x49, 0x46, 0x38}, w.Body.Bytes())
|
||||
require.Len(t, rec.events, 1)
|
||||
}
|
||||
|
||||
func TestHandleTrigger_UnknownTokenStillReturnsArtifactShape(t *testing.T) {
|
||||
gen := &triggerGen{
|
||||
tokenType: token.TypeWebbug,
|
||||
resp: &generators.TriggerResponse{
|
||||
StatusCode: 200,
|
||||
ContentType: "image/gif",
|
||||
Body: []byte{0x47, 0x49, 0x46},
|
||||
},
|
||||
}
|
||||
h, _, rec := newWebbugHandler(t, gen)
|
||||
|
||||
r := chi.NewRouter()
|
||||
h.RegisterTriggerRoutes(r)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/c/nonexistent1", nil))
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code,
|
||||
"unknown tokens still return artifact shape (defense in depth)")
|
||||
require.Empty(t, rec.events, "no event recorded for unknown token")
|
||||
}
|
||||
|
||||
func TestHandleTrigger_DisabledTokenIsTreatedAsUnknown(t *testing.T) {
|
||||
gen := &triggerGen{
|
||||
tokenType: token.TypeWebbug,
|
||||
resp: &generators.TriggerResponse{
|
||||
StatusCode: 200,
|
||||
ContentType: "image/gif",
|
||||
Body: []byte{0x47},
|
||||
},
|
||||
evt: &event.Event{SourceIP: "1.2.3.4"},
|
||||
}
|
||||
h, repo, rec := newWebbugHandler(t, gen)
|
||||
|
||||
tok := &token.Token{
|
||||
ID: "disabled1234", ManageID: "m", Type: token.TypeWebbug,
|
||||
AlertChannel: token.ChannelWebhook, Enabled: false,
|
||||
Metadata: json.RawMessage(`{}`),
|
||||
}
|
||||
require.NoError(t, repo.Insert(context.Background(), tok))
|
||||
|
||||
r := chi.NewRouter()
|
||||
h.RegisterTriggerRoutes(r)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/c/disabled1234", nil))
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Empty(t, rec.events, "disabled token must not record events")
|
||||
}
|
||||
|
||||
func TestHandleFingerprint_Returns204WithNoRecorder(t *testing.T) {
|
||||
gen := &triggerGen{tokenType: token.TypeWebbug}
|
||||
h, _, _ := newWebbugHandler(t, gen)
|
||||
|
||||
r := chi.NewRouter()
|
||||
h.RegisterTriggerRoutes(r)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost,
|
||||
"/c/anything/fingerprint", strings.NewReader(`{"x":1}`)))
|
||||
|
||||
require.Equal(t, http.StatusNoContent, w.Code)
|
||||
}
|
||||
|
||||
func TestArtifactToJSON_Kinds(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in generators.Artifact
|
||||
want token.ArtifactJSON
|
||||
}{
|
||||
{
|
||||
"url",
|
||||
generators.Artifact{
|
||||
Kind: generators.KindURL,
|
||||
URL: "u",
|
||||
DestinationURL: "d",
|
||||
},
|
||||
token.ArtifactJSON{Kind: "url", URL: "u", DestinationURL: "d"},
|
||||
},
|
||||
{
|
||||
"file",
|
||||
generators.Artifact{
|
||||
Kind: generators.KindFile,
|
||||
Filename: "f.docx",
|
||||
ContentType: "x",
|
||||
Content: []byte("hi"),
|
||||
},
|
||||
token.ArtifactJSON{
|
||||
Kind: "file",
|
||||
Filename: "f.docx",
|
||||
ContentType: "x",
|
||||
ContentB64: "aGk=",
|
||||
},
|
||||
},
|
||||
{
|
||||
"text",
|
||||
generators.Artifact{
|
||||
Kind: generators.KindText,
|
||||
Filename: ".env",
|
||||
ContentType: "text/plain",
|
||||
Content: []byte("KEY=v"),
|
||||
},
|
||||
token.ArtifactJSON{
|
||||
Kind: "text",
|
||||
Filename: ".env",
|
||||
ContentType: "text/plain",
|
||||
Content: "KEY=v",
|
||||
},
|
||||
},
|
||||
{
|
||||
"conn",
|
||||
generators.Artifact{
|
||||
Kind: generators.KindConnectionString,
|
||||
ConnectionString: "mysql://x",
|
||||
},
|
||||
token.ArtifactJSON{
|
||||
Kind: "connection_string",
|
||||
ConnectionString: "mysql://x",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
require.Equal(t, c.want, exposeArtifactToJSON(c.in))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func exposeArtifactToJSON(a generators.Artifact) token.ArtifactJSON {
|
||||
gen := &triggerGen{tokenType: token.TypeWebbug, artifact: a}
|
||||
repo := newFakeRepo()
|
||||
svc := token.NewService(repo, token.MapRegistry{token.TypeWebbug: gen},
|
||||
token.ServiceConfig{BaseURL: "https://x"})
|
||||
h := token.NewHandler(svc, nil, nil, quietHandlerLogger())
|
||||
|
||||
r := chi.NewRouter()
|
||||
h.RegisterAPIRoutes(r)
|
||||
|
||||
body := strings.NewReader(
|
||||
`{"type":"webbug","alert_channel":"webhook","webhook_url":"https://x/h","cf_turnstile_response":"t","metadata":{}}`,
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/tokens", body))
|
||||
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Artifact token.ArtifactJSON `json:"artifact"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if jsonErr := json.NewDecoder(w.Body).Decode(&resp); jsonErr != nil {
|
||||
panic(jsonErr)
|
||||
}
|
||||
return resp.Data.Artifact
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
// ©AngelaMos | 2026
|
||||
// types.go
|
||||
|
||||
package token
|
||||
|
||||
type TypeDescriptor struct {
|
||||
Type Type `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ArtifactKind string `json:"artifact_kind"`
|
||||
}
|
||||
|
||||
func TypeDescriptors() []TypeDescriptor {
|
||||
return []TypeDescriptor{
|
||||
{
|
||||
Type: TypeWebbug,
|
||||
Name: "Web Bug Pixel",
|
||||
Description: "1x1 transparent GIF that fires when fetched. Embed in HTML emails or web pages.",
|
||||
ArtifactKind: string(KindURL),
|
||||
},
|
||||
{
|
||||
Type: TypeSlowRedirect,
|
||||
Name: "Slow Redirect",
|
||||
Description: "Browser-fingerprinting page that redirects to a destination URL after collecting client metadata.",
|
||||
ArtifactKind: string(KindURL),
|
||||
},
|
||||
{
|
||||
Type: TypeDocx,
|
||||
Name: "Microsoft Word Document",
|
||||
Description: "DOCX with an embedded INCLUDEPICTURE field that calls home when the document opens.",
|
||||
ArtifactKind: string(KindFile),
|
||||
},
|
||||
{
|
||||
Type: TypePDF,
|
||||
Name: "PDF Document",
|
||||
Description: "PDF with an /AA open-action URI that fires in Adobe Acrobat Reader.",
|
||||
ArtifactKind: string(KindFile),
|
||||
},
|
||||
{
|
||||
Type: TypeKubeconfig,
|
||||
Name: "Kubernetes Config",
|
||||
Description: "kubeconfig pointing kubectl at a fake K8s API server that records every request.",
|
||||
ArtifactKind: string(KindText),
|
||||
},
|
||||
{
|
||||
Type: TypeEnvfile,
|
||||
Name: ".env File",
|
||||
Description: "Plausible production .env with shuffled bait credentials and an embedded canary URL.",
|
||||
ArtifactKind: string(KindText),
|
||||
},
|
||||
{
|
||||
Type: TypeMySQL,
|
||||
Name: "MySQL Connection String",
|
||||
Description: "Fake MySQL endpoint that records any authentication attempt.",
|
||||
ArtifactKind: string(KindConnectionString),
|
||||
},
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue