+3
-2
README.md
+3
-2
README.md
···
82
82
- **`Pages` scheme:** Request includes an `Authorization: Pages <token>` header.
83
83
- **`Basic` scheme:** Request includes an `Authorization: Basic <basic>` header, where `<basic>` is equal to `Base64("Pages:<token>")`. (Useful for non-Forgejo forges.)
84
84
3. **DNS Allowlist:** If the method is `PUT` or `POST`, and a TXT record lookup at `_git-pages-repository.<host>` returns a set of well-formed absolute URLs, and (for `PUT` requests) the body contains a repository URL, and the requested clone URLs is contained in this set of URLs, the request is authorized.
85
-
4. **Wildcard Match (Site):** If the method is `POST`, and a `[[wildcard]]` configuration section exists where the suffix of a hostname (compared label-wise) is equal to `[[wildcard]].domain`, and (for `PUT` requests) the body contains a repository URL, and the requested clone URL is a *matching* clone URL, the request is authorized.
85
+
4. **Wildcard Match (Webhook):** If the method is `POST`, and a `[[wildcard]]` configuration section exists where the suffix of a hostname (compared label-wise) is equal to `[[wildcard]].domain`, and (for `PUT` requests) the body contains a repository URL, and the requested clone URL is a *matching* clone URL, the request is authorized.
86
86
- **Index repository:** If the request URL is `scheme://<user>.<host>/`, a *matching* clone URL is computed by templating `[[wildcard]].clone-url` with `<user>` and `<project>`, where `<project>` is computed by templating each element of `[[wildcard]].index-repos` with `<user>`, and `[[wildcard]]` is the section where the match occurred.
87
87
- **Project repository:** If the request URL is `scheme://<user>.<host>/<project>/`, a *matching* clone URL is computed by templating `[[wildcard]].clone-url` with `<user>` and `<project>`, and `[[wildcard]]` is the section where the match occurred.
88
+
5. **Forge Authorization:** If the method is `PUT`, and the body contains an archive, and a `[[wildcard]]` configuration section exists where the suffix of a hostname (compared label-wise) is equal to `[[wildcard]].domain`, and `[[wildcard]].authorization` is non-empty, and the request includes a `Forge-Authorization:` header, and the header grants push permissions to a repository at the *matching* clone URL (as defined above) as determined by an API call to the forge, the request is authorized.
88
89
5. **Default Deny:** Otherwise, the request is not authorized.
89
90
90
91
The authorization flow for metadata retrieval (`GET` requests with site paths starting with `.git-pages/`) in the following order, with the first of multiple applicable rule taking precedence:
91
92
92
93
1. **Development Mode:** Same as for content updates.
93
94
2. **DNS Challenge:** Same as for content updates.
94
-
3. **Wildcard Match (Domain):** If a `[[wildcard]]` configuration section exists where the suffix of a hostname (compared label-wise) is equal to `[[wildcard]].domain`, the request is authorized.
95
+
3. **Wildcard Match (Metadata):** If a `[[wildcard]]` configuration section exists where the suffix of a hostname (compared label-wise) is equal to `[[wildcard]].domain`, the request is authorized.
95
96
4. **Default Deny:** Otherwise, the request is not authorized.
96
97
97
98
+1
conf/config.example.toml
+1
conf/config.example.toml
+132
-7
src/auth.go
+132
-7
src/auth.go
···
3
3
import (
4
4
"crypto/sha256"
5
5
"encoding/base64"
6
+
"encoding/json"
6
7
"errors"
7
8
"fmt"
8
9
"log"
···
11
12
"net/url"
12
13
"slices"
13
14
"strings"
15
+
"time"
14
16
)
15
17
16
18
type AuthError struct {
···
511
513
}
512
514
}
513
515
516
+
// Gogs, Gitea, and Forgejo all support the same API here.
517
+
func checkGogsRepositoryPushPermission(baseURL *url.URL, authorization string) error {
518
+
ownerAndRepo := strings.TrimSuffix(strings.TrimPrefix(baseURL.Path, "/"), ".git")
519
+
request, err := http.NewRequest("GET", baseURL.ResolveReference(&url.URL{
520
+
Path: fmt.Sprintf("/api/v1/repos/%s", ownerAndRepo),
521
+
}).String(), nil)
522
+
if err != nil {
523
+
panic(err) // misconfiguration
524
+
}
525
+
request.Header.Set("Accept", "application/json")
526
+
request.Header.Set("Authorization", authorization)
527
+
528
+
httpClient := http.Client{Timeout: 5 * time.Second}
529
+
response, err := httpClient.Do(request)
530
+
if err != nil {
531
+
return AuthError{
532
+
http.StatusServiceUnavailable,
533
+
fmt.Sprintf("cannot check repository permissions: %s", err),
534
+
}
535
+
}
536
+
defer response.Body.Close()
537
+
538
+
if response.StatusCode == http.StatusNotFound {
539
+
return AuthError{
540
+
http.StatusNotFound,
541
+
fmt.Sprintf("no repository %s", ownerAndRepo),
542
+
}
543
+
} else if response.StatusCode != http.StatusOK {
544
+
return AuthError{
545
+
http.StatusServiceUnavailable,
546
+
fmt.Sprintf(
547
+
"cannot check repository permissions: GET %s returned %s",
548
+
request.URL,
549
+
response.Status,
550
+
),
551
+
}
552
+
}
553
+
decoder := json.NewDecoder(response.Body)
554
+
555
+
var repositoryInfo struct{ Permissions struct{ Push bool } }
556
+
if err = decoder.Decode(&repositoryInfo); err != nil {
557
+
return errors.Join(AuthError{
558
+
http.StatusServiceUnavailable,
559
+
fmt.Sprintf(
560
+
"cannot check repository permissions: GET %s returned malformed JSON",
561
+
request.URL,
562
+
),
563
+
}, err)
564
+
}
565
+
566
+
if !repositoryInfo.Permissions.Push {
567
+
return AuthError{
568
+
http.StatusUnauthorized,
569
+
fmt.Sprintf("no push permission for %s", ownerAndRepo),
570
+
}
571
+
}
572
+
573
+
// this token authorizes pushing to the repo, yay!
574
+
return nil
575
+
}
576
+
577
+
func authorizeForgeWithToken(r *http.Request) (*Authorization, error) {
578
+
authorization := r.Header.Get("Forge-Authorization")
579
+
if authorization == "" {
580
+
return nil, AuthError{http.StatusUnauthorized, "missing Forge-Authorization header"}
581
+
}
582
+
583
+
host, err := GetHost(r)
584
+
if err != nil {
585
+
return nil, err
586
+
}
587
+
588
+
projectName, err := GetProjectName(r)
589
+
if err != nil {
590
+
return nil, err
591
+
}
592
+
593
+
var errs []error
594
+
for _, pattern := range wildcardPatterns {
595
+
if !pattern.Authorization {
596
+
continue
597
+
}
598
+
599
+
if userName, found := pattern.Matches(host); found {
600
+
repoURLs, branch := pattern.ApplyTemplate(userName, projectName)
601
+
for _, repoURL := range repoURLs {
602
+
parsedRepoURL, err := url.Parse(repoURL)
603
+
if err != nil {
604
+
panic(err) // misconfiguration
605
+
}
606
+
607
+
if err = checkGogsRepositoryPushPermission(parsedRepoURL, authorization); err != nil {
608
+
errs = append(errs, err)
609
+
continue
610
+
}
611
+
612
+
// This will actually be ignored by the caller of AuthorizeUpdateFromArchive,
613
+
// but we return this information as it makes sense to do contextually here.
614
+
return &Authorization{
615
+
[]string{repoURL},
616
+
branch,
617
+
}, nil
618
+
}
619
+
}
620
+
}
621
+
622
+
errs = append([]error{
623
+
AuthError{http.StatusUnauthorized, "not authorized by forge"},
624
+
}, errs...)
625
+
return nil, joinErrors(errs...)
626
+
}
627
+
514
628
func AuthorizeUpdateFromArchive(r *http.Request) (*Authorization, error) {
515
629
causes := []error{AuthError{http.StatusUnauthorized, "unauthorized"}}
516
630
···
523
637
return auth, nil
524
638
}
525
639
526
-
if config.Limits.AllowedRepositoryURLPrefixes != nil {
527
-
return nil, AuthError{http.StatusUnauthorized, "updating from archive not allowed"}
528
-
}
529
-
530
-
// DNS challenge gives absolute authority.
531
-
auth, err := authorizeDNSChallenge(r)
640
+
// Token authorization allows updating a site on a wildcard domain from an archive.
641
+
auth, err := authorizeForgeWithToken(r)
532
642
if err != nil && IsUnauthorized(err) {
533
643
causes = append(causes, err)
534
644
} else if err != nil { // bad request
535
645
return nil, err
536
646
} else {
537
-
log.Println("auth: DNS challenge")
647
+
log.Printf("auth: forge token: allow\n")
538
648
return auth, nil
649
+
}
650
+
651
+
if config.Limits.AllowedRepositoryURLPrefixes != nil {
652
+
causes = append(causes, AuthError{http.StatusUnauthorized, "DNS challenge not allowed"})
653
+
} else {
654
+
// DNS challenge gives absolute authority.
655
+
auth, err = authorizeDNSChallenge(r)
656
+
if err != nil && IsUnauthorized(err) {
657
+
causes = append(causes, err)
658
+
} else if err != nil { // bad request
659
+
return nil, err
660
+
} else {
661
+
log.Println("auth: DNS challenge")
662
+
return auth, nil
663
+
}
539
664
}
540
665
541
666
return nil, joinErrors(causes...)
+1
src/config.go
+1
src/config.go
···
56
56
CloneURL string `toml:"clone-url"`
57
57
IndexRepos []string `toml:"index-repos" default:"[]"`
58
58
IndexRepoBranch string `toml:"index-repo-branch" default:"pages"`
59
+
Authorization string `toml:"authorization"`
59
60
FallbackProxyTo string `toml:"fallback-proxy-to"`
60
61
FallbackInsecure bool `toml:"fallback-insecure"`
61
62
}
+28
-12
src/wildcard.go
+28
-12
src/wildcard.go
···
14
14
)
15
15
16
16
type WildcardPattern struct {
17
-
Domain []string
18
-
CloneURL *fasttemplate.Template
19
-
IndexRepos []*fasttemplate.Template
20
-
IndexBranch string
21
-
FallbackURL *url.URL
22
-
Fallback http.Handler
17
+
Domain []string
18
+
CloneURL *fasttemplate.Template
19
+
IndexRepos []*fasttemplate.Template
20
+
IndexBranch string
21
+
Authorization bool
22
+
FallbackURL *url.URL
23
+
Fallback http.Handler
23
24
}
24
25
25
26
var wildcardPatterns []*WildcardPattern
···
121
122
indexRepoTemplates = append(indexRepoTemplates, indexRepoTemplate)
122
123
}
123
124
125
+
authorization := false
126
+
if config.Authorization != "" {
127
+
if slices.Contains([]string{"gogs", "gitea", "forgejo"}, config.Authorization) {
128
+
// Currently these are the only supported forges, and the authorization mechanism
129
+
// is the same for all of them.
130
+
authorization = true
131
+
} else {
132
+
return fmt.Errorf(
133
+
"wildcard pattern: unknown authorization mechanism: %s",
134
+
config.Authorization,
135
+
)
136
+
}
137
+
}
138
+
124
139
var fallbackURL *url.URL
125
140
var fallback http.Handler
126
141
if config.FallbackProxyTo != "" {
···
144
159
}
145
160
146
161
wildcardPatterns = append(wildcardPatterns, &WildcardPattern{
147
-
Domain: strings.Split(config.Domain, "."),
148
-
CloneURL: cloneURLTemplate,
149
-
IndexRepos: indexRepoTemplates,
150
-
IndexBranch: indexRepoBranch,
151
-
FallbackURL: fallbackURL,
152
-
Fallback: fallback,
162
+
Domain: strings.Split(config.Domain, "."),
163
+
CloneURL: cloneURLTemplate,
164
+
IndexRepos: indexRepoTemplates,
165
+
IndexBranch: indexRepoBranch,
166
+
Authorization: authorization,
167
+
FallbackURL: fallbackURL,
168
+
Fallback: fallback,
153
169
})
154
170
}
155
171
return nil