Check supplied NIP-98 payload hashes even when the request body is empty while preserving the hash requirement for nonempty bodies. Apply the same rule to deferred Git requests and cover empty, missing and mismatched payloads with ordinary regression tests.
- commit
- 3d3a0772f99f7cd59282585ac5ed94b2cbfdf180
- parent
- d0a14b01e65dc0ca2eb9b4f8d2fff7f59add59c6
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 16 | ++++++++++++---- |
| M | internal/ | 25 | +++++++++++++++++++++++++ |
| M | protocol/ | 8 | +++++--- |
| M | protocol/ | 28 | ++++++++++++++++++++++++++++ |
@@ -18,7 +18,7 @@ import (
1818 // pack's size. GitRelay closes the replacement body on every request exit.1919 func spoolGitPayload(r *http.Request, proof event.Event) error {2020 if r.Body == nil || r.Body == http.NoBody {21- return nil21+ return validateGitPayloadHash(proof, emptyPayloadHash(), 0)2222 }2323 file, err := os.CreateTemp("", "tiny-git-authorized-*")2424 if err != nil {@@ -36,12 +36,12 @@ func spoolGitPayload(r *http.Request, proof event.Event) error {
3636 if err != nil {3737 return fmt.Errorf("read Git authorization payload: %w", err)3838 }39+ if err := validateGitPayloadHash(proof, hex.EncodeToString(hash.Sum(nil)), n); err != nil {40+ return err41+ }3942 if n == 0 {4043 return nil4144 }42- if event.Tag(proof, "payload") != hex.EncodeToString(hash.Sum(nil)) {43- return errors.New("auth-required: token payload hash does not match the body")44- }4545 if _, err := file.Seek(0, io.SeekStart); err != nil {4646 return fmt.Errorf("rewind Git authorization payload: %w", err)4747 }@@ -50,6 +50,14 @@ func spoolGitPayload(r *http.Request, proof event.Event) error {
5050 return nil5151 }5252 53+func validateGitPayloadHash(proof event.Event, actual string, size int64) error {54+ payload := event.Tag(proof, "payload")55+ if (size > 0 || payload != "") && payload != actual {56+ return errors.New("auth-required: token payload hash does not match the body")57+ }58+ return nil59+}60+5361 type gitPayloadBody struct {5462 *os.File5563 original io.ReadCloser@@ -70,6 +70,31 @@ func TestGitAuthorizationRejectsMismatchedPayloadWithoutLeavingSpool(t *testing.
7070 }7171 }7272 73+func TestGitAuthorizationValidatesEmptyPayload(t *testing.T) {74+ for _, bodyKind := range []string{"nil", "no body", "empty stream"} {75+ for _, payload := range []string{"", emptyPayloadHash(), strings.Repeat("a", 64)} {76+ t.Run(bodyKind+"/"+payload, func(t *testing.T) {77+ req := httptest.NewRequest(http.MethodPost, "http://relay.test/repo.git/git-receive-pack", nil)78+ switch bodyKind {79+ case "nil":80+ req.Body = nil81+ case "empty stream":82+ req.Body = io.NopCloser(strings.NewReader(""))83+ }84+ proof := event.Event{Tags: [][]string{}}85+ if payload != "" {86+ proof.Tags = append(proof.Tags, []string{"payload", payload})87+ }88+ err := spoolGitPayload(req, proof)89+ wantErr := payload != "" && payload != emptyPayloadHash()90+ if (err != nil) != wantErr {91+ t.Fatalf("validate empty payload: %v, want error=%v", err, wantErr)92+ }93+ })94+ }95+ }96+}97+7398 type unreadGitBody struct{ read bool }7499 75100 func (b *unreadGitBody) Read([]byte) (int, error) {@@ -35,8 +35,9 @@ func NewValidator(now func() time.Time) *Validator {
3535 }3636 3737 // VerifyNIP98 validates a kind 27235 request proof against the exact URL,38-// method and body supplied by the caller. Successfully verified event IDs are39-// retained for the one-minute replay window.38+// method and body supplied by the caller. A payload tag must match even when39+// the body is empty. Successfully verified event IDs are retained for the40+// one-minute replay window.4041 func (v *Validator) VerifyNIP98(header, rawURL, method, body string) (nostr.Event, error) {4142 return v.verifyNIP98(header, rawURL, method, body, true)4243 }@@ -69,7 +70,8 @@ func (v *Validator) verifyNIP98(header, rawURL, method, body string, checkPayloa
6970 if strings.ToUpper(nostr.Tag(e, "method")) != strings.ToUpper(method) {7071 return nostr.Event{}, authError("token was signed for another method")7172 }72- if checkPayload && body != "" && nostr.Tag(e, "payload") != hashBytes([]byte(body)) {73+ payload := nostr.Tag(e, "payload")74+ if checkPayload && (body != "" || payload != "") && payload != hashBytes([]byte(body)) {7375 return nostr.Event{}, authError("token payload hash does not match the body")7476 }7577 if err := v.mark(e.ID, now); err != nil {@@ -50,6 +50,34 @@ func TestNIP98DeferredAllowsPayloadUntilBodyIsMaterialized(t *testing.T) {
5050 }5151 }5252 53+func TestNIP98PayloadMatchesRequestBody(t *testing.T) {54+ now := time.Unix(1_700_000_000, 0)55+ for _, tc := range []struct {56+ name, body, payload string57+ wantErr bool58+ }{59+ {name: "empty without payload"},60+ {name: "empty with matching payload", payload: sha256Hex("")},61+ {name: "empty with wrong payload", payload: sha256Hex("body"), wantErr: true},62+ {name: "body without payload", body: "body", wantErr: true},63+ {name: "body with matching payload", body: "body", payload: sha256Hex("body")},64+ {name: "body with wrong payload", body: "body", payload: sha256Hex("other"), wantErr: true},65+ } {66+ t.Run(tc.name, func(t *testing.T) {67+ validator := NewValidator(func() time.Time { return now })68+ tags := [][]string{{"u", "https://relay.example/api"}, {"method", "POST"}}69+ if tc.payload != "" {70+ tags = append(tags, []string{"payload", tc.payload})71+ }72+ e := signedEvent(t, 27235, now.Unix(), "", tags)73+ _, err := validator.VerifyNIP98(token(t, e), "https://relay.example/api", "POST", tc.body)74+ if (err != nil) != tc.wantErr || err != nil && !strings.Contains(err.Error(), "payload") {75+ t.Fatalf("verify payload: %v, want error=%v", err, tc.wantErr)76+ }77+ })78+ }79+}80+5381 func TestNIP98ExpiryAndPayload(t *testing.T) {5482 now := time.Unix(1_700_000_000, 0)5583 validator := NewValidator(func() time.Time { return now })