Skip to content

Commit 38cf12f

Browse files
frristclaude
andcommitted
Merge origin/main (bucket CORS + shard-inclusion read tier) into fil-588-blob-remove
Union merges throughout (parks/multipart seams alongside the CORS config and the InclusionStore); adopt main's locationFromAdded rename at the PR's call sites; re-pin libforge to the fil-forge/libforge#49 merge commit (v0.0.0-20260727220215-5e299c46f62f), which supersedes both parents' pins now that #49 has merged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 parents ff572c7 + a7684d7 commit 38cf12f

28 files changed

Lines changed: 1191 additions & 136 deletions

config/config.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99

1010
"github.com/spf13/viper"
1111
"go.uber.org/multierr"
12+
13+
"github.com/fil-forge/ingot/internal/cors"
1214
)
1315

1416
// Config is ingot's own configuration.
@@ -29,6 +31,21 @@ type Config struct {
2931
// MaxBlobSize is the blob ceiling for new objects, in bytes (0 -> default
3032
// 256 MiB). An object larger than this is coarsely split into ≤ max blobs.
3133
MaxBlobSize int64 `mapstructure:"max_blob_size" yaml:"max_blob_size"`
34+
// CORSAllowedOrigins lists the browser origins the S3 listener answers
35+
// CORS for. Each entry is an exact origin ("https://app.example"), a
36+
// wildcard origin ("https://*.dev.example" — one '*' standing for any
37+
// run of characters, S3's own matching), or the lone "*" (any origin —
38+
// avoid outside development). Empty disables CORS (the default).
39+
//
40+
// The list is rendered into an S3 CORS configuration document that
41+
// every bucket reports, which is what drives versitygw's CORS
42+
// middlewares and preflight handler. Two consequences worth knowing:
43+
// a matched origin is answered with Access-Control-Allow-Credentials:
44+
// true (AWS behaviour — harmless here because ingot authenticates the
45+
// Authorization header and presigned URLs, never cookies), and the
46+
// service-level routes (ListBuckets, "GET /") are not covered, so
47+
// cross-origin bucket listing is unsupported.
48+
CORSAllowedOrigins []string `mapstructure:"cors_allowed_origins" yaml:"cors_allowed_origins"`
3249
// SealBytes / SealAge / Retain tune the logstore (zero -> logstore defaults).
3350
SealBytes int64 `mapstructure:"seal_bytes" yaml:"seal_bytes"`
3451
SealAge string `mapstructure:"seal_age" yaml:"seal_age"`
@@ -121,6 +138,12 @@ func (c Config) ServerConfig() (ServerConfig, error) {
121138
return ServerConfig{}, fmt.Errorf("ingot: parse multipart_session_ttl %q: %w", c.MultipartSessionTTL, err)
122139
}
123140
}
141+
// Render the CORS configuration here — the single place it is built —
142+
// so a typo fails at startup (via Validate) rather than from New.
143+
corsCfg, err := cors.Build(c.CORSAllowedOrigins)
144+
if err != nil {
145+
return ServerConfig{}, fmt.Errorf("ingot: cors_allowed_origins: %w", err)
146+
}
124147
return ServerConfig{
125148
Addr: c.Addr,
126149
DataDir: c.DataDir,
@@ -129,6 +152,8 @@ func (c Config) ServerConfig() (ServerConfig, error) {
129152
RootSecret: c.RootSecret,
130153
MaxBlobSize: c.MaxBlobSize,
131154

155+
CORSConfig: corsCfg,
156+
132157
// A per-plane override wins, else the top-level value, else the logstore
133158
// default. Ship defaults to true unless the catalog block sets
134159
// `ship: false`.

config/config_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ func TestValidate_RequiredFields(t *testing.T) {
5353
{"upload service", func(c *Config) { c.UploadServiceURL = "" }, "upload_service_url and upload_service_did are required"},
5454
{"auth service", func(c *Config) { c.AuthServiceDID = "" }, "auth_service_url and auth_service_did are required"},
5555
{"bad seal_age", func(c *Config) { c.SealAge = "not-a-duration" }, "parse seal_age"},
56+
{"bad cors origin", func(c *Config) { c.CORSAllowedOrigins = []string{"app.example"} }, "cors_allowed_origins"},
5657
}
5758
for _, tc := range cases {
5859
t.Run(tc.name, func(t *testing.T) {

config/server.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package config
22

3-
import "time"
3+
import (
4+
"time"
5+
6+
"github.com/fil-forge/versitygw/auth"
7+
)
48

59
// ServerConfig captures the user-facing knobs of an ingot S3 listener.
610
// New() applies defaults for any zero-valued knobs. SealAge is in
@@ -48,4 +52,9 @@ type ServerConfig struct {
4852
// idempotency are reaped past the same age. Zero → default 7 days;
4953
// negative → sweeper disabled.
5054
MultipartSessionTTL time.Duration
55+
56+
// CORSConfig is the S3 CORS configuration the backend reports for
57+
// every bucket, rendered from Config.CORSAllowedOrigins by
58+
// internal/cors. Nil disables CORS entirely (the default).
59+
CORSConfig *auth.CORSConfiguration
5160
}

docs/architecture.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -506,7 +506,14 @@ index (stock-tooling plaintext recovery)** — a deliberate trade, not settled h
506506
The **catalog** is different — many tiny MST/manifest blocks share a CAR — so catalog blocks resolve
507507
via the indexer's index-claim / sharded-dag-index path (block CID → byte range in its shard). That path
508508
is retained for the catalog regardless. *(In the R0/R1 appliance topology, catalog-block lookup is
509-
served from the local Postgres location table rather than the indexing-service.)*
509+
served from the local Postgres mirror of that contract: `shard_inclusions` (block digest → shard
510+
digest + byte range, recorded by the flush path before a segment is marked shipped) joined to the
511+
shard's `blob_locations` row. A whole-blob location table alone cannot serve catalog blocks — they
512+
are interior slices of shipped CARs, not stored blobs — which is exactly what breaks
513+
retention-retired catalog reads without the inclusion table. Because the local mirror is what
514+
ingot's own reads depend on, the network index publication (`/index/add` at ship time) is
515+
best-effort: its failure is logged, not a ship failure — a wedged indexer must not wedge
516+
retention. A retry queue for failed publications is a TODO.)*
510517
511518
Consuming a bare location commitment is a capability Ingot's locator must gain: today it surfaces a
512519
location only via the inclusion → shard → commitment path and never returns a stored bare
@@ -784,9 +791,12 @@ These are intentional simplifications of the target topology, not bugs:
784791
Regime-A/B compaction, and subroots ([§6](#6-the-forgechain-layer)) are Piri-side concerns. From Ingot's side a delete is
785792
just `remove(digest)`; Piri decides the on-chain regime. `max_blob_size` is the only size knob Ingot
786793
carries.
787-
- **Local location table instead of the indexer (R0/R1 appliance reduction).** Body-blob locations
788-
are recorded in a local Postgres `blob_locations` table behind a `Locator` seam, rather than read
789-
back through the indexing-service ([§5](#5-the-data-layer), [§8](#8-retrieval-addressing-when-bodies-need-a-sharded-dag-index)). The indexer-backed `Locator` is an `indexer-ready` swap-in.
794+
- **Local location + inclusion tables instead of the indexer (R0/R1 appliance reduction).** Body-blob
795+
and shipped-shard locations are recorded in a local Postgres `blob_locations` table, and each
796+
shipped catalog shard's inner-block byte ranges in `shard_inclusions`, behind a `Locator` seam,
797+
rather than read back through the indexing-service ([§5](#5-the-data-layer), [§8](#8-retrieval-addressing-when-bodies-need-a-sharded-dag-index)). The two tables mirror the
798+
indexing-service contract (location commitments + inclusions), so the indexer-backed `Locator`
799+
remains an `indexer-ready` swap-in.
790800
791801
### Object lifecycle (not implemented)
792802

go.mod

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ require (
77
github.com/aws/aws-sdk-go-v2/config v1.32.26
88
github.com/aws/aws-sdk-go-v2/credentials v1.19.25
99
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.1
10-
github.com/fil-forge/hilt v0.0.1-0.20260716084626-7ddddf09ecc0
10+
github.com/fil-forge/hilt v0.0.1-0.20260724134448-ba71f843f6a4
1111
github.com/fil-forge/indexing-service v1.13.5-0.20260619142411-efe3f5fab717
12-
github.com/fil-forge/libforge v0.0.0-20260723212548-3e5e6ba95711
12+
github.com/fil-forge/libforge v0.0.0-20260727220215-5e299c46f62f
1313
github.com/fil-forge/smelt v0.0.0-20260720130429-63116166a06c
14-
github.com/fil-forge/ucantone v0.0.0-20260706102443-79141c5cc52e
14+
github.com/fil-forge/ucantone v0.0.0-20260727203046-ccb77059de44
1515
github.com/fil-forge/versitygw v0.0.0-20260716095011-7a65883d595a
1616
github.com/fxamacker/cbor/v2 v2.9.2
1717
github.com/go-jose/go-jose/v4 v4.1.4

go.sum

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -246,16 +246,16 @@ github.com/fil-forge/go-ipni-tools v0.0.0-20260519194815-545b9421aec0 h1:HAfXUPv
246246
github.com/fil-forge/go-ipni-tools v0.0.0-20260519194815-545b9421aec0/go.mod h1:3NRV/7wc4/0uzzrGdI7NoN/yeF1UvqKRwMyjBqGc5s0=
247247
github.com/fil-forge/go-ucanto v0.0.0-20260507172450-5cb5d073f8ab h1:2J2cDThqTKP6/0k3SfdlSxfyPa3aLqjTYnmvbEcryfg=
248248
github.com/fil-forge/go-ucanto v0.0.0-20260507172450-5cb5d073f8ab/go.mod h1:lZF3UXZ2hGLKYmXdquG50JqI9pRlUrV6lubGtgOYfwc=
249-
github.com/fil-forge/hilt v0.0.1-0.20260716084626-7ddddf09ecc0 h1:dma5d9PcBPyvMqZTOmoGUeZBKvnZztdko4hWqBpX7ds=
250-
github.com/fil-forge/hilt v0.0.1-0.20260716084626-7ddddf09ecc0/go.mod h1:zzPrQQ/VhgShDIi9IsfeFdiNXJftACRXxUaJI6opW9s=
249+
github.com/fil-forge/hilt v0.0.1-0.20260724134448-ba71f843f6a4 h1:pDDN87a4dMuH8mqqEZZOnrCZ+Myc1ArEjJxg0ZNpQzA=
250+
github.com/fil-forge/hilt v0.0.1-0.20260724134448-ba71f843f6a4/go.mod h1:AO/+NYsz//BoqdHjVfik0fbxjiq+H+86qzGBt2/2uxQ=
251251
github.com/fil-forge/indexing-service v1.13.5-0.20260619142411-efe3f5fab717 h1:Wke8qgaDgy7DGaIS28VpHip+YHSdQP1hGNEZTrXXzb4=
252252
github.com/fil-forge/indexing-service v1.13.5-0.20260619142411-efe3f5fab717/go.mod h1:wFcakLohOqpMRkJzWRdGFGHRFpGB+PpEMCm9wkt2cqU=
253-
github.com/fil-forge/libforge v0.0.0-20260723212548-3e5e6ba95711 h1:xO5gwfL3W2wqaLvwFUDj4MuWX9wHK9TGV8F3jFe/mZM=
254-
github.com/fil-forge/libforge v0.0.0-20260723212548-3e5e6ba95711/go.mod h1:0kXihIQ4L2uZ00nR5XrZ/Y8Db7Ht/qQNuiWslwMJ95M=
253+
github.com/fil-forge/libforge v0.0.0-20260727220215-5e299c46f62f h1:QzgMg8GIE4IhgOE/7DBHFU/4T5oU7J4vAKxbUPKJPGA=
254+
github.com/fil-forge/libforge v0.0.0-20260727220215-5e299c46f62f/go.mod h1:0kXihIQ4L2uZ00nR5XrZ/Y8Db7Ht/qQNuiWslwMJ95M=
255255
github.com/fil-forge/smelt v0.0.0-20260720130429-63116166a06c h1:WHvsleEU6ZiNYDFgLx6KtorXulD+IuLiorMgmp4Th8s=
256256
github.com/fil-forge/smelt v0.0.0-20260720130429-63116166a06c/go.mod h1:NM/mk/XiP1Kzsy9HWGQeLsosPUvVY3bIrkUzqswThpU=
257-
github.com/fil-forge/ucantone v0.0.0-20260706102443-79141c5cc52e h1:di/SseJVEO6bznSH/UEnAE2nSwwp/gqgnPLgE9/x2Zg=
258-
github.com/fil-forge/ucantone v0.0.0-20260706102443-79141c5cc52e/go.mod h1:oFY5BfD0bDeodGlbBHh3/nK99MAS93rGXjoQz7s5qgE=
257+
github.com/fil-forge/ucantone v0.0.0-20260727203046-ccb77059de44 h1:ofvb2Qq7++VPRelGsLbtnd1ZMKVT4n4QGoa79BhJ6VQ=
258+
github.com/fil-forge/ucantone v0.0.0-20260727203046-ccb77059de44/go.mod h1:oFY5BfD0bDeodGlbBHh3/nK99MAS93rGXjoQz7s5qgE=
259259
github.com/fil-forge/versitygw v0.0.0-20260716095011-7a65883d595a h1:lDwnNmF4LNbevx/YYNCC0CazMiL4eIe38MvfqbRR3RA=
260260
github.com/fil-forge/versitygw v0.0.0-20260716095011-7a65883d595a/go.mod h1:t73Wa2xqpT0NdY+SzRZDiO+tQD13b3UEZRoe2wYFiIE=
261261
github.com/filecoin-project/go-data-segment v0.0.1 h1:1wmDxOG4ubWQm3ZC1XI5nCon5qgSq7Ra3Rb6Dbu10Gs=

inmem/store.go

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,14 @@ type MemStore struct {
5454
// The architecture's relational surface (docs/architecture.md §5–§7),
5555
// mirroring the Postgres tables so the in-process suite exercises the
5656
// same code paths. See stores.go for the methods over these.
57-
blobRefs map[claimKey]registry.BlobClaim
58-
intents map[string]registry.UploadIntent // keyed by string(digest)
59-
locations map[locKey]registry.BlobLocation // keyed by (space, digest)
60-
parks map[string]registry.BlobPark // keyed by string(digest)
61-
sessions map[string]registry.MultipartSession // keyed by uploadID
62-
parts map[string]map[int]registry.MultipartPart // uploadID -> partNumber -> part
63-
gcCands map[string]struct{} // keyed by string(cid)
57+
blobRefs map[claimKey]registry.BlobClaim
58+
intents map[string]registry.UploadIntent // keyed by string(digest)
59+
locations map[locKey]registry.BlobLocation // keyed by (space, digest)
60+
inclusions map[locKey]registry.BlobInclusion // keyed by (space, digest)
61+
parks map[string]registry.BlobPark // keyed by string(digest)
62+
sessions map[string]registry.MultipartSession // keyed by uploadID
63+
parts map[string]map[int]registry.MultipartPart // uploadID -> partNumber -> part
64+
gcCands map[string]struct{} // keyed by string(cid)
6465
}
6566

6667
// claimKey / locKey are the composite map keys for the blob_refs and
@@ -77,15 +78,16 @@ type locKey struct {
7778
// NewMemStore returns an empty MemStore.
7879
func NewMemStore() *MemStore {
7980
return &MemStore{
80-
buckets: map[string]*registry.State{},
81-
segments: map[uint64]*logstore.SegmentMeta{},
82-
blobRefs: map[claimKey]registry.BlobClaim{},
83-
intents: map[string]registry.UploadIntent{},
84-
locations: map[locKey]registry.BlobLocation{},
85-
parks: map[string]registry.BlobPark{},
86-
sessions: map[string]registry.MultipartSession{},
87-
parts: map[string]map[int]registry.MultipartPart{},
88-
gcCands: map[string]struct{}{},
81+
buckets: map[string]*registry.State{},
82+
segments: map[uint64]*logstore.SegmentMeta{},
83+
blobRefs: map[claimKey]registry.BlobClaim{},
84+
intents: map[string]registry.UploadIntent{},
85+
locations: map[locKey]registry.BlobLocation{},
86+
inclusions: map[locKey]registry.BlobInclusion{},
87+
parks: map[string]registry.BlobPark{},
88+
sessions: map[string]registry.MultipartSession{},
89+
parts: map[string]map[int]registry.MultipartPart{},
90+
gcCands: map[string]struct{}{},
8991
}
9092
}
9193

@@ -341,8 +343,8 @@ func (NopBaseReader) OpenBlob(_ context.Context, _ did.DID, _ multihash.Multihas
341343
// network, so the spool's local copy serves all reads.
342344
type NopUploader struct{}
343345

344-
func (NopUploader) SubmitShard(_ context.Context, _ blockstore.Plane, _ did.DID, _ uploader.CARShard) error {
345-
return nil
346+
func (NopUploader) SubmitShard(_ context.Context, _ blockstore.Plane, _ did.DID, _ uploader.CARShard) (uploader.BlobLocation, error) {
347+
return uploader.BlobLocation{}, nil
346348
}
347349

348350
func (NopUploader) UploadBlob(_ context.Context, _ did.DID, _ multihash.Multihash, size int64, _ string) (uploader.BlobLocation, error) {

inmem/stores.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ var (
2020
_ registry.BlobRefStore = (*MemStore)(nil)
2121
_ registry.IntentStore = (*MemStore)(nil)
2222
_ registry.LocationStore = (*MemStore)(nil)
23+
_ registry.InclusionStore = (*MemStore)(nil)
2324
_ registry.MultipartStore = (*MemStore)(nil)
2425
_ registry.GCStore = (*MemStore)(nil)
2526
)
@@ -32,7 +33,6 @@ func cloneBytes(b []byte) []byte {
3233
}
3334

3435
// BlobRefStore ===============================================================
35-
3636
func (m *MemStore) AddBlobClaim(_ context.Context, c registry.BlobClaim) error {
3737
m.mu.Lock()
3838
defer m.mu.Unlock()
@@ -186,6 +186,33 @@ func (m *MemStore) DeletePark(_ context.Context, digest []byte) error {
186186
return nil
187187
}
188188

189+
// InclusionStore =============================================================
190+
191+
func (m *MemStore) PutInclusions(_ context.Context, incs []registry.BlobInclusion) error {
192+
m.mu.Lock()
193+
defer m.mu.Unlock()
194+
for _, inc := range incs {
195+
cp := inc
196+
cp.Digest = cloneBytes(inc.Digest)
197+
cp.ShardDigest = cloneBytes(inc.ShardDigest)
198+
m.inclusions[locKey{inc.Space, string(inc.Digest)}] = cp
199+
}
200+
return nil
201+
}
202+
203+
func (m *MemStore) GetInclusion(_ context.Context, space did.DID, digest []byte) (*registry.BlobInclusion, error) {
204+
m.mu.Lock()
205+
defer m.mu.Unlock()
206+
inc, ok := m.inclusions[locKey{space, string(digest)}]
207+
if !ok {
208+
return nil, registry.ErrNotFound
209+
}
210+
cp := inc
211+
cp.Digest = cloneBytes(inc.Digest)
212+
cp.ShardDigest = cloneBytes(inc.ShardDigest)
213+
return &cp, nil
214+
}
215+
189216
// MultipartStore =============================================================
190217

191218
func (m *MemStore) CreateSession(_ context.Context, s registry.MultipartSession) error {

internal/cors/cors.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Package cors renders ingot's cors_allowed_origins into the S3 CORS
2+
// configuration the listener reports for every bucket.
3+
//
4+
// versitygw drives all of its CORS behaviour off the backend's
5+
// GetBucketCors — ApplyBucketCORS is attached to every bucket/object
6+
// route and ctrl.CORSOptions answers preflights from the same document
7+
// (s3api/router.go, s3api/controllers/options.go) — so ingot's whole job
8+
// is producing a valid configuration. s3frontend marshals and serves it;
9+
// nothing here matches origins or touches a request.
10+
package cors
11+
12+
import (
13+
"fmt"
14+
"net/http"
15+
"strings"
16+
17+
"github.com/fil-forge/versitygw/auth"
18+
)
19+
20+
// allowedMethods are the S3 verbs the rule permits. These are exactly
21+
// the methods auth.CORSHTTPMethod.IsValid accepts.
22+
var allowedMethods = []auth.CORSHTTPMethod{
23+
http.MethodGet,
24+
http.MethodHead,
25+
http.MethodPut,
26+
http.MethodPost,
27+
http.MethodDelete,
28+
}
29+
30+
// exposeHeaders are the response headers browser JavaScript may read on a
31+
// cross-origin response. ETag is the one S3 clients can't live without
32+
// (PUT/multipart verification) and is listed explicitly because the
33+
// preflight controller doesn't apply versitygw's ensureExposeETag
34+
// fallback; the x-amz-* set covers request tracing and versioning.
35+
var exposeHeaders = []auth.CORSHeader{
36+
"ETag",
37+
"x-amz-storage-class",
38+
"x-amz-request-id",
39+
"x-amz-id-2",
40+
"x-amz-version-id",
41+
}
42+
43+
// maxAgeSeconds caps how long a browser may cache a preflight result.
44+
// Without it browsers fall back to ~5s and re-preflight almost every
45+
// request — an extra round trip per PUT for a browser client.
46+
const maxAgeSeconds int32 = 600
47+
48+
// Build renders origins as a single-rule S3 CORS configuration. An empty
49+
// list yields (nil, nil): CORS disabled, which s3frontend reports as
50+
// NoSuchCORSConfiguration so versitygw's CORS middlewares fall through
51+
// untouched.
52+
//
53+
// Origins are matched by versitygw at request time with S3 semantics
54+
// (auth.wildcardMatch): an exact origin, or one '*' standing for any run
55+
// of characters ("https://*.dev.example"). Matching is over the raw
56+
// Origin header, so a non-default port must be spelled out.
57+
func Build(origins []string) (*auth.CORSConfiguration, error) {
58+
if len(origins) == 0 {
59+
return nil, nil
60+
}
61+
62+
allowed := make([]auth.CORSOrigin, 0, len(origins))
63+
for _, raw := range origins {
64+
o := strings.ToLower(strings.TrimSpace(raw))
65+
if err := validateOrigin(raw, o); err != nil {
66+
return nil, err
67+
}
68+
allowed = append(allowed, auth.CORSOrigin(o))
69+
}
70+
71+
maxAge := maxAgeSeconds
72+
cfg := &auth.CORSConfiguration{
73+
Rules: []auth.CORSRule{{
74+
AllowedOrigins: allowed,
75+
AllowedMethods: allowedMethods,
76+
// Every requested header must match an entry or
77+
// CORSRule.Match rejects the preflight, and S3 clients send
78+
// an open-ended x-amz-* set alongside authorization.
79+
AllowedHeaders: []auth.CORSHeader{"*"},
80+
ExposeHeaders: exposeHeaders,
81+
MaxAgeSeconds: &maxAge,
82+
}},
83+
}
84+
if err := cfg.Validate(); err != nil {
85+
return nil, fmt.Errorf("cors: %w", err)
86+
}
87+
return cfg, nil
88+
}
89+
90+
// validateOrigin rejects anything that couldn't be a browser Origin.
91+
// versitygw's own CORSOrigin.Validate only rejects a second '*', so
92+
// without this a typo like "app.example" would be accepted and then
93+
// silently never match; raw is carried through for the error message.
94+
func validateOrigin(raw, o string) error {
95+
if o == "" {
96+
return fmt.Errorf("cors: empty origin")
97+
}
98+
if o == "*" {
99+
return nil
100+
}
101+
rest, ok := strings.CutPrefix(o, "https://")
102+
if !ok {
103+
rest, ok = strings.CutPrefix(o, "http://")
104+
}
105+
if !ok {
106+
return fmt.Errorf("cors: origin %q must start with http:// or https://", raw)
107+
}
108+
// '@' rejects userinfo (https://user@host): an Origin header is only
109+
// scheme+host(+port), so such an entry could never match a request.
110+
if rest == "" || strings.ContainsAny(rest, "/?#@") {
111+
return fmt.Errorf("cors: origin %q must be a bare origin (scheme://host[:port], no path or userinfo)", raw)
112+
}
113+
if strings.Count(o, "*") > 1 {
114+
return fmt.Errorf("cors: origin %q: at most one '*' is allowed", raw)
115+
}
116+
return nil
117+
}

0 commit comments

Comments
 (0)