Monorepo for Tangled tangled.org
771
fork

Configure Feed

Select the types of activity you want to include in your feed.

appview,knotserver: validate git repo ownership according to knot #281

open opened by oyster.cafe targeting master from lt/repo-rename-by-rkey
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mjwlqwlv7f22
+823 -15
Diff #0
+39
api/tangled/repodescribeRepo.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.describeRepo 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoDescribeRepoNSID = "sh.tangled.repo.describeRepo" 15 + ) 16 + 17 + // RepoDescribeRepo_Output is the output of a sh.tangled.repo.describeRepo call. 18 + type RepoDescribeRepo_Output struct { 19 + // ownerDid: DID of the current owner according to the knot. 20 + OwnerDid string `json:"ownerDid" cborgen:"ownerDid"` 21 + RepoDid string `json:"repoDid" cborgen:"repoDid"` 22 + // rkey: Current rkey of the sh.tangled.repo record tracked by this knot 23 + Rkey string `json:"rkey" cborgen:"rkey"` 24 + } 25 + 26 + // RepoDescribeRepo calls the XRPC method "sh.tangled.repo.describeRepo". 27 + // 28 + // repoDid: DID of the git repo as minted by the knot 29 + func RepoDescribeRepo(ctx context.Context, c util.LexClient, repoDid string) (*RepoDescribeRepo_Output, error) { 30 + var out RepoDescribeRepo_Output 31 + 32 + params := map[string]interface{}{} 33 + params["repoDid"] = repoDid 34 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.describeRepo", params, nil, &out); err != nil { 35 + return nil, err 36 + } 37 + 38 + return &out, nil 39 + }
+2
appview/ingester.go
··· 29 29 "tangled.org/core/appview/db" 30 30 "tangled.org/core/appview/models" 31 31 "tangled.org/core/appview/notify" 32 + "tangled.org/core/appview/repoverify" 32 33 "tangled.org/core/appview/serververify" 33 34 "tangled.org/core/appview/validator" 34 35 "tangled.org/core/idresolver" ··· 45 46 Logger *slog.Logger 46 47 Validator *validator.Validator 47 48 Notifier notify.Notifier 49 + Verifier repoverify.Verifier 48 50 } 49 51 50 52 type processFunc func(ctx context.Context, e *jmodels.Event) error
+61 -1
appview/ingester_repo.go
··· 6 6 "encoding/json" 7 7 "errors" 8 8 "fmt" 9 + "log/slog" 9 10 "slices" 11 + "strings" 10 12 11 13 "github.com/bluesky-social/indigo/atproto/syntax" 12 14 jmodels "github.com/bluesky-social/jetstream/pkg/models" 13 15 "tangled.org/core/api/tangled" 14 16 "tangled.org/core/appview/db" 15 17 "tangled.org/core/appview/models" 18 + "tangled.org/core/appview/repoverify" 16 19 "tangled.org/core/orm" 17 20 ) 18 21 ··· 47 50 } 48 51 repoDid := *record.RepoDid 49 52 50 - _, err := db.GetRepo(i.Db, 53 + proceed, err := i.verifyOwnership(ctx, l, repoDid, e.Did, record.Knot) 54 + if err != nil { 55 + return err 56 + } 57 + if !proceed { 58 + return nil 59 + } 60 + 61 + _, err = db.GetRepo(i.Db, 51 62 orm.FilterEq("did", e.Did), 52 63 orm.FilterEq("rkey", e.Commit.RKey), 53 64 ) ··· 165 176 return nil 166 177 } 167 178 179 + proceed, err := i.verifyOwnership(ctx, l, *record.RepoDid, e.Did, record.Knot) 180 + if err != nil { 181 + return err 182 + } 183 + if !proceed { 184 + return nil 185 + } 186 + 168 187 current, err := db.GetRepo(i.Db, 169 188 orm.FilterEq("did", e.Did), 170 189 orm.FilterEq("rkey", e.Commit.RKey), ··· 177 196 return fmt.Errorf("failed to fetch repo for ingest: %w", err) 178 197 } 179 198 199 + if current.RepoDid != "" && current.RepoDid != *record.RepoDid { 200 + l.Warn("rejecting repo update: repoDid is immutable", 201 + "currentRepoDid", current.RepoDid, 202 + "recordRepoDid", *record.RepoDid, 203 + ) 204 + return nil 205 + } 206 + 180 207 desired := repoFromRecord(current, &record) 181 208 182 209 if current.Source != desired.Source { ··· 330 357 } 331 358 return *s 332 359 } 360 + 361 + func (i *Ingester) verifyOwnership(ctx context.Context, l *slog.Logger, repoDid, eventDid, recordKnot string) (bool, error) { 362 + if i.Verifier == nil { 363 + return false, fmt.Errorf("ingester has no repo ownership verifier configured") 364 + } 365 + rd, err := repoverify.NewRepoDid(repoDid) 366 + if err != nil { 367 + l.Warn("rejecting repo event: invalid repoDid on record", "repoDid", repoDid, "err", err) 368 + return false, nil 369 + } 370 + result, err := i.Verifier(ctx, rd) 371 + if err != nil { 372 + return false, fmt.Errorf("verify repo ownership: %w", err) 373 + } 374 + if result.OwnerDid.String() != eventDid { 375 + l.Warn("rejecting repo event: owner mismatch", 376 + "repoDid", repoDid, 377 + "claimedOwner", eventDid, 378 + "knotOwner", result.OwnerDid.String(), 379 + "knot", result.KnotURL.String(), 380 + ) 381 + return false, nil 382 + } 383 + if !strings.EqualFold(recordKnot, result.KnotURL.Host) { 384 + l.Warn("rejecting repo event: record knot does not match DID-doc endpoint", 385 + "repoDid", repoDid, 386 + "recordKnot", recordKnot, 387 + "canonicalKnot", result.KnotURL.Host, 388 + ) 389 + return false, nil 390 + } 391 + return true, nil 392 + }
+311 -14
appview/ingester_repo_test.go
··· 7 7 "errors" 8 8 "io" 9 9 "log/slog" 10 + "net/url" 10 11 "path/filepath" 11 12 "testing" 12 13 ··· 16 17 "tangled.org/core/appview/db" 17 18 "tangled.org/core/appview/models" 18 19 "tangled.org/core/appview/notify" 20 + "tangled.org/core/appview/repoverify" 19 21 "tangled.org/core/orm" 20 22 ) 21 23 24 + func mustKnotURL(t *testing.T, raw string) *url.URL { 25 + t.Helper() 26 + u, err := repoverify.ParseKnotEndpoint(raw, true) 27 + if err != nil { 28 + t.Fatalf("ParseKnotEndpoint(%q): %v", raw, err) 29 + } 30 + return u 31 + } 32 + 33 + func acceptOwner(t *testing.T, e *jmodels.Event) repoverify.Verifier { 34 + t.Helper() 35 + knot := mustKnotURL(t, "https://knot.example") 36 + return func(_ context.Context, repoDid repoverify.RepoDid) (repoverify.Result, error) { 37 + return repoverify.Result{ 38 + RepoDid: repoDid, 39 + OwnerDid: repoverify.OwnerDid(e.Did), 40 + KnotURL: knot, 41 + }, nil 42 + } 43 + } 44 + 45 + func stubVerifier(result repoverify.Result, err error) repoverify.Verifier { 46 + return func(_ context.Context, _ repoverify.RepoDid) (repoverify.Result, error) { 47 + return result, err 48 + } 49 + } 50 + 22 51 type spyNotifier struct { 23 52 notify.BaseNotifier 24 53 creates int ··· 50 79 return ing, spy 51 80 } 52 81 82 + func withVerifier(ing *Ingester, v repoverify.Verifier) *Ingester { 83 + ing.Verifier = v 84 + return ing 85 + } 86 + 87 + func ingestAcceptingOwner(t *testing.T, ing *Ingester, e *jmodels.Event) error { 88 + t.Helper() 89 + ing.Verifier = acceptOwner(t, e) 90 + return ing.ingestRepo(context.Background(), e) 91 + } 92 + 53 93 func seedRepoRow(t *testing.T, ing *Ingester, did, knot, name, rkey, repoDid string) *models.Repo { 54 94 t.Helper() 55 95 tx, err := ing.Db.Begin() ··· 126 166 RepoDid: ptr("did:plc:repo1"), 127 167 }) 128 168 129 - if err := ing.ingestRepo(context.Background(), e); err != nil { 169 + if err := ingestAcceptingOwner(t, ing, e); err != nil { 130 170 t.Fatalf("ingestRepo: %v", err) 131 171 } 132 172 ··· 155 195 RepoDid: ptr("did:plc:repo1"), 156 196 }) 157 197 158 - if err := ing.ingestRepo(context.Background(), e); err != nil { 198 + if err := ingestAcceptingOwner(t, ing, e); err != nil { 159 199 t.Fatalf("ingestRepo: %v", err) 160 200 } 161 201 if spy.creates != 0 { ··· 173 213 RepoDid: ptr("did:plc:repo1"), 174 214 }) 175 215 176 - if err := ing.ingestRepo(context.Background(), e); err != nil { 216 + if err := ingestAcceptingOwner(t, ing, e); err != nil { 177 217 t.Fatalf("ingestRepo: %v", err) 178 218 } 179 219 ··· 217 257 Name: ptr("myrepo"), 218 258 }) 219 259 220 - if err := ing.ingestRepo(context.Background(), e); err != nil { 260 + if err := ingestAcceptingOwner(t, ing, e); err != nil { 221 261 t.Fatalf("ingestRepo: %v", err) 222 262 } 223 263 if spy.creates != 0 { ··· 238 278 RepoDid: ptr("did:plc:repo1"), 239 279 }) 240 280 241 - if err := ing.ingestRepo(context.Background(), e); err != nil { 281 + if err := ingestAcceptingOwner(t, ing, e); err != nil { 242 282 t.Fatalf("ingestRepo: %v", err) 243 283 } 244 284 ··· 264 304 RepoDid: ptr("did:plc:repo1"), 265 305 }) 266 306 267 - if err := ing.ingestRepo(context.Background(), e); err != nil { 307 + if err := ingestAcceptingOwner(t, ing, e); err != nil { 268 308 t.Fatalf("ingestRepo: %v", err) 269 309 } 270 310 ··· 287 327 RepoDid: ptr("did:plc:repo1"), 288 328 }) 289 329 290 - if err := ing.ingestRepo(context.Background(), e); err != nil { 330 + if err := ingestAcceptingOwner(t, ing, e); err != nil { 291 331 t.Fatalf("ingestRepo: %v", err) 292 332 } 293 333 ··· 315 355 e = makeDeleteEvent("did:plc:nobody", "ghost") 316 356 } 317 357 318 - if err := ing.ingestRepo(context.Background(), e); err != nil { 358 + if err := ingestAcceptingOwner(t, ing, e); err != nil { 319 359 t.Fatalf("ingestRepo: %v", err) 320 360 } 321 361 }) ··· 331 371 Name: ptr("bar"), 332 372 }) 333 373 334 - if err := ing.ingestRepo(context.Background(), e); err != nil { 374 + if err := ingestAcceptingOwner(t, ing, e); err != nil { 335 375 t.Fatalf("ingestRepo: %v", err) 336 376 } 337 377 ··· 346 386 seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "foo", "foo", "did:plc:repo1") 347 387 348 388 e := makeDeleteEvent("did:plc:akshay", "foo") 349 - if err := ing.ingestRepo(context.Background(), e); err != nil { 389 + if err := ingestAcceptingOwner(t, ing, e); err != nil { 350 390 t.Fatalf("ingestRepo: %v", err) 351 391 } 352 392 ··· 373 413 }, 374 414 } 375 415 376 - if err := ing.ingestRepo(context.Background(), e); err == nil { 416 + if err := ingestAcceptingOwner(t, ing, e); err == nil { 377 417 t.Errorf("ingestRepo with malformed record: err = nil, want error") 378 418 } 379 419 } ··· 394 434 Name: ptr("NewName"), 395 435 RepoDid: ptr("did:plc:repo1"), 396 436 }) 397 - if err := ing.ingestRepo(context.Background(), createEvt); err != nil { 437 + if err := ingestAcceptingOwner(t, ing, createEvt); err != nil { 398 438 t.Fatalf("ingest create: %v", err) 399 439 } 400 440 401 441 deleteEvt := makeDeleteEvent("did:plc:akshay", "oldname") 402 - if err := ing.ingestRepo(context.Background(), deleteEvt); err != nil { 442 + if err := ingestAcceptingOwner(t, ing, deleteEvt); err != nil { 403 443 t.Fatalf("ingest delete: %v", err) 404 444 } 405 445 ··· 446 486 RepoDid: ptr("did:plc:repo1"), 447 487 }) 448 488 449 - if err := ing.ingestRepo(context.Background(), e); err != nil { 489 + if err := ingestAcceptingOwner(t, ing, e); err != nil { 450 490 t.Fatalf("ingestRepo: %v", err) 451 491 } 452 492 ··· 455 495 t.Errorf("name should fall back to rkey: got %q, want %q", r.Name, "myrepo") 456 496 } 457 497 } 498 + 499 + func TestIngestRepo_CreateSquatRejected(t *testing.T) { 500 + ing, spy := newTestIngester(t) 501 + 502 + e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:boltless", "squatrepo", tangled.Repo{ 503 + Knot: "knot.example", 504 + RepoDid: ptr("did:plc:akshays-repo"), 505 + }) 506 + 507 + withVerifier(ing, stubVerifier(repoverify.Result{ 508 + RepoDid: "did:plc:akshays-repo", 509 + OwnerDid: "did:plc:akshay", 510 + KnotURL: mustKnotURL(t, "https://knot.example"), 511 + }, nil)) 512 + 513 + if err := ing.ingestRepo(context.Background(), e); err != nil { 514 + t.Fatalf("ingestRepo: %v", err) 515 + } 516 + 517 + if _, err := db.GetRepo(ing.Db, 518 + orm.FilterEq("did", "did:plc:boltless"), 519 + orm.FilterEq("rkey", "squatrepo"), 520 + ); !errors.Is(err, sql.ErrNoRows) { 521 + t.Fatalf("boltless's squat row should not exist, got err=%v", err) 522 + } 523 + if spy.creates != 0 { 524 + t.Errorf("NewRepo called %d times despite rejection", spy.creates) 525 + } 526 + } 527 + 528 + func TestIngestRepo_CreateHijackExistingRepoRejected(t *testing.T) { 529 + ing, spy := newTestIngester(t) 530 + seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "myrepo", "akshayskey", "did:plc:akshays-repo") 531 + 532 + e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:boltless", "takeover", tangled.Repo{ 533 + Knot: "knot.example", 534 + RepoDid: ptr("did:plc:akshays-repo"), 535 + }) 536 + 537 + withVerifier(ing, stubVerifier(repoverify.Result{ 538 + RepoDid: "did:plc:akshays-repo", 539 + OwnerDid: "did:plc:akshay", 540 + KnotURL: mustKnotURL(t, "https://knot.example"), 541 + }, nil)) 542 + 543 + if err := ing.ingestRepo(context.Background(), e); err != nil { 544 + t.Fatalf("ingestRepo: %v", err) 545 + } 546 + 547 + akshay := loadRepo(t, ing, "did:plc:akshay", "akshayskey") 548 + if akshay.Did != "did:plc:akshay" || akshay.Rkey != "akshayskey" { 549 + t.Errorf("akshay's row mutated: %+v", akshay) 550 + } 551 + if spy.renames != 0 { 552 + t.Errorf("RenameRepo called %d times despite rejection", spy.renames) 553 + } 554 + } 555 + 556 + func TestIngestRepo_CreateRenameIgnoresRkeyDrift(t *testing.T) { 557 + ing, spy := newTestIngester(t) 558 + seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "oldname", "oldrkey", "did:plc:akshays-repo") 559 + 560 + e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "newrkey", tangled.Repo{ 561 + Knot: "knot.example", 562 + Name: ptr("newname"), 563 + RepoDid: ptr("did:plc:akshays-repo"), 564 + }) 565 + 566 + withVerifier(ing, stubVerifier(repoverify.Result{ 567 + RepoDid: "did:plc:akshays-repo", 568 + OwnerDid: "did:plc:akshay", 569 + KnotURL: mustKnotURL(t, "https://knot.example"), 570 + }, nil)) 571 + 572 + if err := ing.ingestRepo(context.Background(), e); err != nil { 573 + t.Fatalf("ingestRepo: %v", err) 574 + } 575 + 576 + r := loadRepo(t, ing, "did:plc:akshay", "newrkey") 577 + if r.Name != "newname" { 578 + t.Errorf("rename did not apply despite matching owner: name=%q", r.Name) 579 + } 580 + if spy.renames != 1 { 581 + t.Errorf("RenameRepo called %d times, want 1", spy.renames) 582 + } 583 + } 584 + 585 + func TestIngestRepo_CreateVerifierTransientErrorPropagates(t *testing.T) { 586 + ing, spy := newTestIngester(t) 587 + 588 + e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ 589 + Knot: "knot.example", 590 + RepoDid: ptr("did:plc:akshays-repo"), 591 + }) 592 + 593 + withVerifier(ing, stubVerifier(repoverify.Result{}, errors.New("knot unreachable"))) 594 + 595 + err := ing.ingestRepo(context.Background(), e) 596 + if err == nil { 597 + t.Fatalf("expected error on transient verifier failure, got nil") 598 + } 599 + if spy.creates != 0 { 600 + t.Errorf("NewRepo called %d times despite verifier error", spy.creates) 601 + } 602 + } 603 + 604 + func TestIngestRepo_UpdateRejectsOwnerMismatch(t *testing.T) { 605 + ing, _ := newTestIngester(t) 606 + seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "myrepo", "akshayskey", "did:plc:akshays-repo") 607 + 608 + e := makeEvent(t, jmodels.CommitOperationUpdate, "did:plc:boltless", "akshayskey", tangled.Repo{ 609 + Knot: "knot.example", 610 + Description: ptr("boltless hijacks metadata"), 611 + RepoDid: ptr("did:plc:akshays-repo"), 612 + }) 613 + 614 + withVerifier(ing, stubVerifier(repoverify.Result{ 615 + RepoDid: "did:plc:akshays-repo", 616 + OwnerDid: "did:plc:akshay", 617 + KnotURL: mustKnotURL(t, "https://knot.example"), 618 + }, nil)) 619 + 620 + if err := ing.ingestRepo(context.Background(), e); err != nil { 621 + t.Fatalf("ingestRepo: %v", err) 622 + } 623 + 624 + akshay := loadRepo(t, ing, "did:plc:akshay", "akshayskey") 625 + if akshay.Description == "boltless hijacks metadata" { 626 + t.Errorf("update by non-owner applied: %+v", akshay) 627 + } 628 + } 629 + 630 + func TestIngestRepo_CreateInvalidRepoDidRejected(t *testing.T) { 631 + ing, spy := newTestIngester(t) 632 + 633 + e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ 634 + Knot: "knot.example", 635 + RepoDid: ptr("did:plc:"), 636 + }) 637 + 638 + verifierCalled := false 639 + withVerifier(ing, func(_ context.Context, _ repoverify.RepoDid) (repoverify.Result, error) { 640 + verifierCalled = true 641 + return repoverify.Result{}, nil 642 + }) 643 + 644 + if err := ing.ingestRepo(context.Background(), e); err != nil { 645 + t.Fatalf("ingestRepo: %v", err) 646 + } 647 + if verifierCalled { 648 + t.Errorf("verifier was called with an invalid repoDid") 649 + } 650 + if spy.creates != 0 { 651 + t.Errorf("NewRepo called %d times despite invalid repoDid", spy.creates) 652 + } 653 + } 654 + 655 + func TestIngestRepo_NilVerifierFailsClosed(t *testing.T) { 656 + ing, spy := newTestIngester(t) 657 + 658 + e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ 659 + Knot: "knot.example", 660 + RepoDid: ptr("did:plc:akshays-repo"), 661 + }) 662 + 663 + err := ing.ingestRepo(context.Background(), e) 664 + if err == nil { 665 + t.Fatalf("expected error when Verifier is nil, got nil") 666 + } 667 + if spy.creates != 0 { 668 + t.Errorf("NewRepo called %d times despite nil verifier", spy.creates) 669 + } 670 + } 671 + 672 + func TestIngestRepo_CreateRejectsKnotMismatch(t *testing.T) { 673 + ing, spy := newTestIngester(t) 674 + 675 + e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ 676 + Knot: "evil.example", 677 + RepoDid: ptr("did:plc:akshays-repo"), 678 + }) 679 + 680 + withVerifier(ing, stubVerifier(repoverify.Result{ 681 + RepoDid: "did:plc:akshays-repo", 682 + OwnerDid: "did:plc:akshay", 683 + KnotURL: mustKnotURL(t, "https://knot.example"), 684 + }, nil)) 685 + 686 + if err := ing.ingestRepo(context.Background(), e); err != nil { 687 + t.Fatalf("ingestRepo: %v", err) 688 + } 689 + if _, err := db.GetRepo(ing.Db, 690 + orm.FilterEq("did", "did:plc:akshay"), 691 + orm.FilterEq("rkey", "myrepo"), 692 + ); !errors.Is(err, sql.ErrNoRows) { 693 + t.Fatalf("row should not be created for spoofed knot, err=%v", err) 694 + } 695 + if spy.creates != 0 { 696 + t.Errorf("NewRepo called %d times despite knot mismatch", spy.creates) 697 + } 698 + } 699 + 700 + func TestIngestRepo_UpdateRejectsKnotMismatch(t *testing.T) { 701 + ing, _ := newTestIngester(t) 702 + seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "myrepo", "akshayskey", "did:plc:akshays-repo") 703 + 704 + e := makeEvent(t, jmodels.CommitOperationUpdate, "did:plc:akshay", "akshayskey", tangled.Repo{ 705 + Knot: "evil.example", 706 + Description: ptr("redirected clone target"), 707 + RepoDid: ptr("did:plc:akshays-repo"), 708 + }) 709 + 710 + withVerifier(ing, stubVerifier(repoverify.Result{ 711 + RepoDid: "did:plc:akshays-repo", 712 + OwnerDid: "did:plc:akshay", 713 + KnotURL: mustKnotURL(t, "https://knot.example"), 714 + }, nil)) 715 + 716 + if err := ing.ingestRepo(context.Background(), e); err != nil { 717 + t.Fatalf("ingestRepo: %v", err) 718 + } 719 + akshay := loadRepo(t, ing, "did:plc:akshay", "akshayskey") 720 + if akshay.Description == "redirected clone target" { 721 + t.Errorf("update with spoofed knot applied: %+v", akshay) 722 + } 723 + if akshay.Knot != "knot.example" { 724 + t.Errorf("row knot mutated to %q, want knot.example", akshay.Knot) 725 + } 726 + } 727 + 728 + func TestIngestRepo_UpdateRejectsRepoDidMutation(t *testing.T) { 729 + ing, _ := newTestIngester(t) 730 + seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "myrepo", "akshayskey", "did:plc:akshays-repo") 731 + 732 + e := makeEvent(t, jmodels.CommitOperationUpdate, "did:plc:akshay", "akshayskey", tangled.Repo{ 733 + Knot: "knot.example", 734 + Description: ptr("sneaky repoDid swap"), 735 + RepoDid: ptr("did:plc:other-repo"), 736 + }) 737 + 738 + withVerifier(ing, stubVerifier(repoverify.Result{ 739 + RepoDid: "did:plc:other-repo", 740 + OwnerDid: "did:plc:akshay", 741 + KnotURL: mustKnotURL(t, "https://knot.example"), 742 + }, nil)) 743 + 744 + if err := ing.ingestRepo(context.Background(), e); err != nil { 745 + t.Fatalf("ingestRepo: %v", err) 746 + } 747 + akshay := loadRepo(t, ing, "did:plc:akshay", "akshayskey") 748 + if akshay.RepoDid != "did:plc:akshays-repo" { 749 + t.Errorf("repoDid mutated to %q, want did:plc:akshays-repo", akshay.RepoDid) 750 + } 751 + if akshay.Description == "sneaky repoDid swap" { 752 + t.Errorf("metadata from repoDid-mutating update applied: %+v", akshay) 753 + } 754 + }
+155
appview/repoverify/verify.go
··· 1 + package repoverify 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net" 7 + "net/http" 8 + "net/url" 9 + "syscall" 10 + "time" 11 + 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 14 + "tangled.org/core/api/tangled" 15 + "tangled.org/core/appview/xrpcclient" 16 + "tangled.org/core/idresolver" 17 + ) 18 + 19 + type RepoDid syntax.DID 20 + 21 + func (r RepoDid) String() string { return string(r) } 22 + 23 + func NewRepoDid(s string) (RepoDid, error) { 24 + did, err := syntax.ParseDID(s) 25 + if err != nil { 26 + return "", fmt.Errorf("invalid repoDid %q: %w", s, err) 27 + } 28 + return RepoDid(did), nil 29 + } 30 + 31 + type OwnerDid syntax.DID 32 + 33 + func (o OwnerDid) String() string { return string(o) } 34 + 35 + func NewOwnerDid(s string) (OwnerDid, error) { 36 + did, err := syntax.ParseDID(s) 37 + if err != nil { 38 + return "", fmt.Errorf("invalid ownerDid %q: %w", s, err) 39 + } 40 + return OwnerDid(did), nil 41 + } 42 + 43 + func ParseKnotEndpoint(raw string, dev bool) (*url.URL, error) { 44 + if raw == "" { 45 + return nil, fmt.Errorf("empty knot URL") 46 + } 47 + u, err := url.Parse(raw) 48 + if err != nil { 49 + return nil, fmt.Errorf("invalid knot URL %q: %w", raw, err) 50 + } 51 + if u.Host == "" { 52 + return nil, fmt.Errorf("knot URL %q has no host", raw) 53 + } 54 + switch u.Scheme { 55 + case "https": 56 + case "http": 57 + if !dev { 58 + return nil, fmt.Errorf("knot URL %q must use https outside dev mode", raw) 59 + } 60 + default: 61 + return nil, fmt.Errorf("knot URL %q has unsupported scheme %q", raw, u.Scheme) 62 + } 63 + return u, nil 64 + } 65 + 66 + type Result struct { 67 + RepoDid RepoDid 68 + OwnerDid OwnerDid 69 + KnotURL *url.URL 70 + } 71 + 72 + type Verifier func(ctx context.Context, repoDid RepoDid) (Result, error) 73 + 74 + const verifyTimeout = 10 * time.Second 75 + 76 + func New(resolver *idresolver.Resolver, dev bool) Verifier { 77 + transport := &http.Transport{ 78 + DialContext: safeDialer(dev).DialContext, 79 + } 80 + httpClient := &http.Client{ 81 + Timeout: verifyTimeout, 82 + Transport: transport, 83 + } 84 + 85 + return func(ctx context.Context, repoDid RepoDid) (Result, error) { 86 + ctx, cancel := context.WithTimeout(ctx, verifyTimeout) 87 + defer cancel() 88 + return resolveAndDescribe(ctx, resolver, httpClient, repoDid, dev) 89 + } 90 + } 91 + 92 + func resolveAndDescribe( 93 + ctx context.Context, 94 + resolver *idresolver.Resolver, 95 + httpClient *http.Client, 96 + repoDid RepoDid, 97 + dev bool, 98 + ) (Result, error) { 99 + ident, err := resolver.ResolveIdent(ctx, repoDid.String()) 100 + if err != nil { 101 + return Result{}, fmt.Errorf("resolve repoDid %s: %w", repoDid, err) 102 + } 103 + 104 + knot, err := ParseKnotEndpoint(ident.GetServiceEndpoint("atproto_pds"), dev) 105 + if err != nil { 106 + return Result{}, fmt.Errorf("repoDid %s: %w", repoDid, err) 107 + } 108 + 109 + client := &indigoxrpc.Client{Host: knot.String(), Client: httpClient} 110 + out, err := tangled.RepoDescribeRepo(ctx, client, repoDid.String()) 111 + if xrpcErr := xrpcclient.HandleXrpcErr(err); xrpcErr != nil { 112 + return Result{}, fmt.Errorf("describeRepo on %s: %w", knot, xrpcErr) 113 + } 114 + 115 + if out.RepoDid != repoDid.String() { 116 + return Result{}, fmt.Errorf("knot %s returned mismatched repoDid: got %q, want %q", knot, out.RepoDid, repoDid) 117 + } 118 + 119 + ownerDid, err := NewOwnerDid(out.OwnerDid) 120 + if err != nil { 121 + return Result{}, fmt.Errorf("describeRepo on %s returned invalid ownerDid: %w", knot, err) 122 + } 123 + 124 + return Result{ 125 + RepoDid: repoDid, 126 + OwnerDid: ownerDid, 127 + KnotURL: knot, 128 + }, nil 129 + } 130 + 131 + func safeDialer(dev bool) *net.Dialer { 132 + d := &net.Dialer{ 133 + Timeout: 5 * time.Second, 134 + KeepAlive: 30 * time.Second, 135 + } 136 + if dev { 137 + return d 138 + } 139 + d.Control = func(network, address string, _ syscall.RawConn) error { 140 + host, _, err := net.SplitHostPort(address) 141 + if err != nil { 142 + return fmt.Errorf("invalid dial address %q: %w", address, err) 143 + } 144 + ip := net.ParseIP(host) 145 + if ip == nil { 146 + return fmt.Errorf("dial address %q did not resolve to IP", address) 147 + } 148 + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || 149 + ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() { 150 + return fmt.Errorf("refusing to dial %s: reserved or private address", ip) 151 + } 152 + return nil 153 + } 154 + return d 155 + }
+66
appview/repoverify/verify_test.go
··· 1 + package repoverify 2 + 3 + import "testing" 4 + 5 + func TestNewRepoDid_RejectsInvalid(t *testing.T) { 6 + if _, err := NewRepoDid(""); err == nil { 7 + t.Error("NewRepoDid(\"\") err = nil, want error") 8 + } 9 + } 10 + 11 + func TestNewRepoDid_AcceptsValid(t *testing.T) { 12 + raw := "did:plc:abc123abc123abc123abc123" 13 + got, err := NewRepoDid(raw) 14 + if err != nil { 15 + t.Fatalf("NewRepoDid(%q): %v", raw, err) 16 + } 17 + if got.String() != raw { 18 + t.Errorf("got %q, want %q", got, raw) 19 + } 20 + } 21 + 22 + func TestParseKnotEndpoint_RejectsHttpInProd(t *testing.T) { 23 + if _, err := ParseKnotEndpoint("http://knot.example", false); err == nil { 24 + t.Error("http:// knot URL accepted in prod") 25 + } 26 + } 27 + 28 + func TestParseKnotEndpoint_AllowsHttpInDev(t *testing.T) { 29 + u, err := ParseKnotEndpoint("http://knot.example", true) 30 + if err != nil { 31 + t.Fatalf("dev mode should allow http: %v", err) 32 + } 33 + if u.Host != "knot.example" { 34 + t.Errorf("Host = %q, want knot.example", u.Host) 35 + } 36 + } 37 + 38 + func TestParseKnotEndpoint_RejectsUnsupportedScheme(t *testing.T) { 39 + if _, err := ParseKnotEndpoint("ftp://knot.example", true); err == nil { 40 + t.Error("ParseKnotEndpoint accepted ftp:// in dev") 41 + } 42 + if _, err := ParseKnotEndpoint("ftp://knot.example", false); err == nil { 43 + t.Error("ParseKnotEndpoint accepted ftp:// in prod") 44 + } 45 + } 46 + 47 + func TestParseKnotEndpoint_RejectsEmptyOrHostless(t *testing.T) { 48 + cases := []string{"", "https://", "not a url at all"} 49 + for _, raw := range cases { 50 + t.Run(raw, func(t *testing.T) { 51 + if _, err := ParseKnotEndpoint(raw, false); err == nil { 52 + t.Errorf("ParseKnotEndpoint(%q) accepted bogus URL", raw) 53 + } 54 + }) 55 + } 56 + } 57 + 58 + func TestParseKnotEndpoint_HostPreservesPort(t *testing.T) { 59 + u, err := ParseKnotEndpoint("http://localhost:3000", true) 60 + if err != nil { 61 + t.Fatalf("ParseKnotEndpoint: %v", err) 62 + } 63 + if u.Host != "localhost:3000" { 64 + t.Errorf("Host = %q, want localhost:3000", u.Host) 65 + } 66 + }
+2
appview/state/state.go
··· 29 29 "tangled.org/core/appview/oauth" 30 30 "tangled.org/core/appview/pages" 31 31 "tangled.org/core/appview/reporesolver" 32 + "tangled.org/core/appview/repoverify" 32 33 "tangled.org/core/appview/validator" 33 34 xrpcclient "tangled.org/core/appview/xrpcclient" 34 35 "tangled.org/core/consts" ··· 181 182 Logger: log.SubLogger(logger, "ingester"), 182 183 Validator: validator, 183 184 Notifier: notifier, 185 + Verifier: repoverify.New(res, config.Core.Dev), 184 186 } 185 187 err = jc.StartJetstream(ctx, ingester.Ingest()) 186 188 if err != nil {
+39
knotserver/xrpc/repo_describe_repo.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "database/sql" 5 + "errors" 6 + "net/http" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/api/tangled" 10 + xrpcerr "tangled.org/core/xrpc/errors" 11 + ) 12 + 13 + func (x *Xrpc) RepoDescribeRepo(w http.ResponseWriter, r *http.Request) { 14 + raw := r.URL.Query().Get("repoDid") 15 + repoDid, err := syntax.ParseDID(raw) 16 + if err != nil { 17 + writeError(w, xrpcerr.NewXrpcError( 18 + xrpcerr.WithTag("InvalidRequest"), 19 + xrpcerr.WithMessage("missing or invalid repoDid parameter"), 20 + ), http.StatusBadRequest) 21 + return 22 + } 23 + 24 + ownerDid, rkey, err := x.Db.GetRepoKeyOwner(repoDid.String()) 25 + if errors.Is(err, sql.ErrNoRows) { 26 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNotFound) 27 + return 28 + } 29 + if err != nil { 30 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 31 + return 32 + } 33 + 34 + x.writeJson(w, tangled.RepoDescribeRepo_Output{ 35 + RepoDid: repoDid.String(), 36 + OwnerDid: ownerDid, 37 + Rkey: rkey, 38 + }) 39 + }
+94
knotserver/xrpc/repo_describe_repo_test.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "io" 7 + "log/slog" 8 + "net/http" 9 + "net/http/httptest" 10 + "net/url" 11 + "path/filepath" 12 + "testing" 13 + 14 + "tangled.org/core/api/tangled" 15 + "tangled.org/core/knotserver/config" 16 + "tangled.org/core/knotserver/db" 17 + ) 18 + 19 + func newTestXrpc(t *testing.T) *Xrpc { 20 + t.Helper() 21 + d, err := db.Setup(context.Background(), filepath.Join(t.TempDir(), "test.db")) 22 + if err != nil { 23 + t.Fatalf("db.Setup: %v", err) 24 + } 25 + return &Xrpc{ 26 + Db: d, 27 + Config: &config.Config{Server: config.Server{Hostname: "knot.example", MaxResponseKB: 5120}}, 28 + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), 29 + } 30 + } 31 + 32 + func TestRepoDescribeRepo_ReturnsOwner(t *testing.T) { 33 + x := newTestXrpc(t) 34 + if err := x.Db.StoreRepoKey("did:plc:repo1", []byte("dummy"), "did:plc:akshay", "myrepo"); err != nil { 35 + t.Fatalf("StoreRepoKey: %v", err) 36 + } 37 + 38 + req := httptest.NewRequest(http.MethodGet, "/xrpc/sh.tangled.repo.describeRepo?repoDid=did:plc:repo1", nil) 39 + rec := httptest.NewRecorder() 40 + x.RepoDescribeRepo(rec, req) 41 + 42 + if rec.Code != http.StatusOK { 43 + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) 44 + } 45 + 46 + var out tangled.RepoDescribeRepo_Output 47 + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { 48 + t.Fatalf("decode: %v", err) 49 + } 50 + if out.RepoDid != "did:plc:repo1" { 51 + t.Errorf("RepoDid = %q", out.RepoDid) 52 + } 53 + if out.OwnerDid != "did:plc:akshay" { 54 + t.Errorf("OwnerDid = %q, want did:plc:akshay", out.OwnerDid) 55 + } 56 + if out.Rkey != "myrepo" { 57 + t.Errorf("Rkey = %q, want myrepo", out.Rkey) 58 + } 59 + } 60 + 61 + func TestRepoDescribeRepo_UnknownRepoDidReturns404(t *testing.T) { 62 + x := newTestXrpc(t) 63 + 64 + req := httptest.NewRequest(http.MethodGet, "/xrpc/sh.tangled.repo.describeRepo?repoDid=did:plc:unknown", nil) 65 + rec := httptest.NewRecorder() 66 + x.RepoDescribeRepo(rec, req) 67 + 68 + if rec.Code != http.StatusNotFound { 69 + t.Errorf("status = %d, want 404", rec.Code) 70 + } 71 + } 72 + 73 + func TestRepoDescribeRepo_MissingParamReturns400(t *testing.T) { 74 + x := newTestXrpc(t) 75 + 76 + req := httptest.NewRequest(http.MethodGet, "/xrpc/sh.tangled.repo.describeRepo", nil) 77 + rec := httptest.NewRecorder() 78 + x.RepoDescribeRepo(rec, req) 79 + 80 + if rec.Code != http.StatusBadRequest { 81 + t.Errorf("status = %d, want 400", rec.Code) 82 + } 83 + } 84 + 85 + func TestRepoDescribeRepo_MalformedDidParamReturns400(t *testing.T) { 86 + x := newTestXrpc(t) 87 + u := "/xrpc/sh.tangled.repo.describeRepo?repoDid=" + url.QueryEscape("notadid") 88 + req := httptest.NewRequest(http.MethodGet, u, nil) 89 + rec := httptest.NewRecorder() 90 + x.RepoDescribeRepo(rec, req) 91 + if rec.Code != http.StatusBadRequest { 92 + t.Errorf("status = %d, want 400", rec.Code) 93 + } 94 + }
+1
knotserver/xrpc/xrpc.go
··· 67 67 r.Get("/"+tangled.RepoDiffNSID, x.RepoDiff) 68 68 r.Get("/"+tangled.RepoCompareNSID, x.RepoCompare) 69 69 r.Get("/"+tangled.RepoGetDefaultBranchNSID, x.RepoGetDefaultBranch) 70 + r.Get("/"+tangled.RepoDescribeRepoNSID, x.RepoDescribeRepo) 70 71 r.Get("/"+tangled.RepoBranchNSID, x.RepoBranch) 71 72 r.Get("/"+tangled.RepoArchiveNSID, x.RepoArchive) 72 73 r.Get("/"+tangled.RepoLanguagesNSID, x.RepoLanguages)
+53
lexicons/repo/describeRepo.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.describeRepo", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Fetch the knot's authoritative metadata for a git repo DID.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["repoDid"], 11 + "properties": { 12 + "repoDid": { 13 + "type": "string", 14 + "format": "did", 15 + "description": "DID of the git repo as minted by the knot" 16 + } 17 + } 18 + }, 19 + "output": { 20 + "encoding": "application/json", 21 + "schema": { 22 + "type": "object", 23 + "required": ["repoDid", "ownerDid", "rkey"], 24 + "properties": { 25 + "repoDid": { 26 + "type": "string", 27 + "format": "did" 28 + }, 29 + "ownerDid": { 30 + "type": "string", 31 + "format": "did", 32 + "description": "DID of the current owner according to the knot." 33 + }, 34 + "rkey": { 35 + "type": "string", 36 + "description": "Current rkey of the sh.tangled.repo record tracked by this knot" 37 + } 38 + } 39 + } 40 + }, 41 + "errors": [ 42 + { 43 + "name": "RepoNotFound", 44 + "description": "Repo DID is not registered on this knot" 45 + }, 46 + { 47 + "name": "InvalidRequest", 48 + "description": "Invalid request parameters" 49 + } 50 + ] 51 + } 52 + } 53 + }

History

1 round 0 comments
sign up or login to add to the discussion
oyster.cafe submitted #0
1 commit
expand
appview,knotserver: validate git repo ownership according to knot
no conflicts, ready to merge
expand 0 comments