Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions backend/internal/web/reception/browser/headers/headers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package headers

const (
CacheControl = "Cache-Control" // (Request/Response) Directives for caching mechanisms. Eg. `no-cache, no-store, must-revalidate`
Connection = "Connection" // (Request/Response) Controls options for the connection. Eg. `keep-alive`
ContentEncoding = "Content-Encoding" // (Request/Response) Compression applied to the body. Eg. `gzip`
ContentLanguage = "Content-Language" // (Request/Response) Natural language of the body. Eg. `en`
ContentLength = "Content-Length" // (Request/Response) Size of the body in bytes. Eg. `348`
ContentLocation = "Content-Location" // (Request/Response) Alternate location for returned data. Eg. `/index.htm`
ContentRange = "Content-Range" // (Request/Response) Part of a full body returned. Eg. `bytes 200-1000/67589`
ContentType = "Content-Type" // (Request/Response) Media type of the body. Eg. `application/json; charset=utf-8`
Date = "Date" // (Request/Response) Date/time of message origination. Eg. `Tue, 15 Nov 1994 08:12:31 GMT`
Pragma = "Pragma" // (Request/Response) Implementation-specific directives (deprecated, replaced by Cache-Control). Eg. `no-cache`
Trailer = "Trailer" // (Request/Response) Headers present after the body in chunked transfer. Eg. `Expires`
)

const (
Accept = "Accept" // (Request) Media types the client can handle. Eg. `text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8`
AcceptCharset = "Accept-Charset" // (Request) Character sets accepted. Eg. `utf-8, iso-8859-1;q=0.5`
AcceptDatetime = "Accept-Datetime" // (Request) (Experimental) Acceptable version-date of resource. Eg. `Thu, 31 May 2007 20:35:00 GMT`
AcceptEncoding = "Accept-Encoding" // (Request) Content codings the client can handle. Eg. `gzip, deflate, br`
AcceptLanguage = "Accept-Language" // (Request) Preferred natural languages. Eg. `en-US,en;q=0.5`
AccessControlRequestHeaders = "Access-Control-Request-Headers" // (Request) Used in CORS preflight to indicate custom headers. Eg. `Content-Type, Authorization`
AccessControlRequestMethod = "Access-Control-Request-Method" // (Request) Used in CORS preflight to indicate method. Eg. `POST`
AIM = "A-IM" // (Request) Instance-manipulations the client supports. Eg. `feed`
Authorization = "Authorization" // (Request) Credentials for authentication. Eg. `Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==`
Cookie = "Cookie" // (Request) Client cookies. Eg. `sessionId=abc123; theme=light`
Expect = "Expect" // (Request) Indicates expectations. Commonly `100-continue`. Eg. `100-continue`
Forwarded = "Forwarded" // (Request) Proxy information. Eg. `for=192.0.2.60;proto=http;by=203.0.113.43`
From = "From" // (Request) Email address of user making request. Eg. `ufuktan@ufukty.com`
Host = "Host" // (Request) Hostname of server. Required in HTTP/1.1. Eg. `ufukty.com`
IfMatch = "If-Match" // (Request) Conditional request: proceed if ETag matches. Eg. `"737060cd8c284d8af7ad3082f209582d"`
IfModifiedSince = "If-Modified-Since" // (Request) Conditional GET: send if newer. Eg. `Sat, 29 Oct 1994 19:43:31 GMT`
IfNoneMatch = "If-None-Match" // (Request) Conditional GET: send if ETag doesn’t match. Eg. `"737060cd8c284d8af7ad3082f209582d"`
IfRange = "If-Range" // (Request) Conditional range request based on ETag or date. Eg. `"737060cd8c284d8af7ad3082f209582d"`
IfUnmodifiedSince = "If-Unmodified-Since" // (Request) Conditional: proceed only if resource not modified since. Eg. `Sat, 29 Oct 1994 19:43:31 GMT`
MaxForwards = "Max-Forwards" // (Request) Limits proxy/forwarding hops. Eg. `10`
Origin = "Origin" // (Request) Origin of the request for CORS. Eg. `https://ufukty.com`
ProxyAuthorization = "Proxy-Authorization" // (Request) Credentials for proxy authentication. Eg. `Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==`
Range = "Range" // (Request) Requests partial resource. Eg. `bytes=200-1000`
Referer = "Referer" // (Request) Page that linked to resource. Eg. `http://www.ufukty.com/start.html`
SecFetchDest = "Sec-Fetch-Dest" // (Request) Fetch metadata: request destination. Eg. `document` | `image` | `script` | `style` | `iframe`
SecFetchMode = "Sec-Fetch-Mode" // (Request) Fetch metadata: mode. Eg. `cors` | `no-cors` | `same-origin` | `navigate`
SecFetchSite = "Sec-Fetch-Site" // (Request) Fetch metadata: relationship of origin. Eg. `same-origin` | `same-site` | `cross-site` | `none`
SecFetchUser = "Sec-Fetch-User" // (Request) Fetch metadata: user activation. Eg. `?1`
TE = "TE" // (Request) Transfer encodings accepted. Eg. `trailers, deflate`
Upgrade = "Upgrade" // (Request) Protocol upgrade (e.g. WebSocket). Eg. `websocket`
UserAgent = "User-Agent" // (Request) Client software identifier. Eg. `Mozilla/5.0 (Windows NT 10.0; Win64; x64)`
)

const (
AcceptRanges = "Accept-Ranges" // (Response) Indicates if server supports partial requests. Eg. `bytes`
AccessControlAllowCredentials = "Access-Control-Allow-Credentials" // (Response) Whether response can expose credentials (CORS). Eg. `true`
AccessControlAllowHeaders = "Access-Control-Allow-Headers" // (Response) Headers permitted in CORS request. Eg. `Content-Type, Authorization, X-Custom-Header`
AccessControlAllowMethods = "Access-Control-Allow-Methods" // (Response) Methods permitted in CORS request. Eg. `GET, POST, PUT, DELETE`
AccessControlAllowOrigin = "Access-Control-Allow-Origin" // (Response) Allowed origin(s) for CORS. Eg. `*` OR `https://ufukty.com`
AccessControlExposeHeaders = "Access-Control-Expose-Headers" // (Response) Headers accessible to scripts (CORS). Eg. `Content-Length, X-Kuma-Revision`
AccessControlMaxAge = "Access-Control-Max-Age" // (Response) How long preflight response can be cached. Eg. `600`
Age = "Age" // (Response) Time since response was generated (seconds). Eg. `3600`
Allow = "Allow" // (Response) Valid methods for the resource. Eg. `GET, POST, HEAD`
ETag = "ETag" // (Response) Identifier for specific resource version. Eg. `"33a64df551425fcc55e4d42a148795d9f25f89d4"`
Expires = "Expires" // (Response) Date/time after which response is stale. Eg. `Wed, 21 Oct 2015 07:28:00 GMT`
LastModified = "Last-Modified" // (Response) Timestamp of last modification. Eg. `Tue, 15 Nov 1994 12:45:26 GMT`
Location = "Location" // (Response) Redirect target or new resource URI. Eg. `http://www.ufukty.com/newpage.html`
ProxyAuthenticate = "Proxy-Authenticate" // (Response) Authentication method required by proxy. Eg. `Basic realm="Access to the staging site"`
RetryAfter = "Retry-After" // (Response) When client can retry request. Eg. `120`
Server = "Server" // (Response) Server software details. Eg. `nginx/1.14.1`
SetCookie = "Set-Cookie" // (Response) Send cookies from server. Eg. `sessionId=abc123; Path=/; HttpOnly`
TransferEncoding = "Transfer-Encoding" // (Response) Encoding form of body transfer. Eg. `chunked`
Vary = "Vary" // (Response) Headers that affect response selection. Eg. `Accept-Encoding`
Via = "Via" // (Response) Intermediate proxies information. Eg. `1.0 fred, 1.1 ufukty.com (Apache/1.1)`
Warning = "Warning" // (Response) Additional info about status/transformations. Eg. `199 Miscellaneous warning`
WWWAuthenticate = "WWW-Authenticate" // (Response) Authentication method required by server. Eg. `Basic realm="Access to the site"`
)
95 changes: 95 additions & 0 deletions backend/internal/web/reception/browser/matcher.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package browser

import (
"maps"
"net/http"
"slices"
"strings"
)

type allowance struct {
methods map[string]any
headers map[string]any
}

func lookup(ss []string, canonicalize func(string) string) map[string]any {
us := make(map[string]any, len(ss))
for _, s := range ss {
us[canonicalize(s)] = nil
}
return us
}

func has[K comparable, V any](m map[K]V, k K) bool {
_, ok := m[k]
return ok
}

func contains(asked string, allowed map[string]any, canonicalize func(string) string) bool {
return has(allowed, canonicalize(asked))
}

func containsAll(asked []string, allowed map[string]any, canonicalize func(string) string) bool {
for _, a := range asked {
if !contains(a, allowed, canonicalize) {
return false
}
}
return true
}

type StringMatcher interface {
MatchString(s string) bool
}

// Matcher uses custom matcher for origin and path;
// lowercase character matching for methods and headers
type Matcher struct {
origin StringMatcher
path StringMatcher
allowance *allowance
}

func NewMatcher(origin, path StringMatcher, allowedmethods, allowedheaders []string) *Matcher {
return &Matcher{
origin: origin,
path: path,
allowance: &allowance{
methods: lookup(allowedmethods, strings.ToLower),
headers: lookup(allowedheaders, http.CanonicalHeaderKey),
},
}
}

type Scope struct {
Methods []string
Headers []string
}

func (m Matcher) Match(origin, method, path string, headers []string) *Scope {
if origin == "" || path == "" || method == "" {
return nil
}
if !m.origin.MatchString(origin) || !m.path.MatchString(path) {
return nil
}
if !contains(method, m.allowance.methods, strings.ToLower) {
return nil
}
if !containsAll(headers, m.allowance.headers, http.CanonicalHeaderKey) {
return nil
}
return &Scope{
Methods: slices.Collect(maps.Keys(m.allowance.methods)),
Headers: slices.Collect(maps.Keys(m.allowance.headers)),
}
}

func matchAny(ms []*Matcher, origin, method, path string, headers []string) *Scope {
for _, m := range ms {
if a := m.Match(origin, method, path, headers); a != nil {
return a
}
}
return nil
}
80 changes: 80 additions & 0 deletions backend/internal/web/reception/browser/matcher_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package browser

import (
"fmt"
"net/http"
"regexp"
"testing"
)

func s[T any](ss ...T) []T {
return ss
}

func TestMatcherMatch(t *testing.T) {
// cases belong scenarios
type (
tc struct {
Origin string
Path string
Method string
Headers []string
}
ts struct {
Matcher *Matcher // shared among cases
Positive map[string]tc
Negative map[string]tc
}
)
tsc := map[string]ts{
"hardcoded origin and path": {
Matcher: NewMatcher(regexp.MustCompile("localhost:3000"), regexp.MustCompile("/user"), s(http.MethodGet), []string{"Content-Type"}),
Positive: map[string]tc{
"": {"localhost:3000", "/user", http.MethodGet, []string{"Content-Type"}},
},
Negative: map[string]tc{
"cross origin": {"localhost:8080", "/user", http.MethodGet, []string{}},
"cross path": {"localhost:3000", "/account", http.MethodGet, []string{}},
},
},
"wildcard origin and path": {
Matcher: NewMatcher(regexp.MustCompile(".*"), regexp.MustCompile(".*"), s(http.MethodGet), s("Content-Type")),
Positive: map[string]tc{
"member": {"localhost", "/user", http.MethodGet, s("Content-Type")},
},
Negative: map[string]tc{
"empty method": {"localhost", "/user", "", s("Content-Type")},
"unallowed method": {"localhost", "/user", http.MethodPost, s("Content-Type")},
"unallowed header": {"localhost", "/user", http.MethodGet, s("Cookie")},
},
},
"no headers": {
Matcher: NewMatcher(regexp.MustCompile("localhost:3000"), regexp.MustCompile("/user"), s(http.MethodGet), []string{}),
Positive: map[string]tc{
"": {"localhost:3000", "/user", http.MethodGet, []string{}},
},
Negative: map[string]tc{
"cross origin": {"localhost:8080", "/user", http.MethodGet, []string{}},
"cross path": {"localhost:3000", "/account", http.MethodGet, []string{}},
},
},
}

for tsn, ts := range tsc {
for tcn, tc := range ts.Positive {
t.Run(fmt.Sprintf("%q should ALLOW %q", tsn, tcn), func(t *testing.T) {
if a := ts.Matcher.Match(tc.Origin, tc.Path, tc.Method, tc.Headers); a == nil {
t.Errorf("expected match")
}
})
}

for tcn, tc := range ts.Negative {
t.Run(fmt.Sprintf("%q should UNALLOW %q", tsn, tcn), func(t *testing.T) {
if a := ts.Matcher.Match(tc.Origin, tc.Path, tc.Method, tc.Headers); a != nil {
t.Errorf("unexpected match")
}
})
}
}
}
69 changes: 69 additions & 0 deletions backend/internal/web/reception/browser/origin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package browser

import (
"logbook/internal/web/reception/browser/headers"
"maps"
"net/http"
"slices"
"strings"
)

// OriginChecker performs (path, method, headers) permission checking
// both for the cross/same origin and preflight/actual requests
type OriginChecker struct {
Allowances []*Matcher
}

func (c OriginChecker) preflight(w http.ResponseWriter, r *http.Request) {
allowance := matchAny(c.Allowances,
r.Header.Get(headers.Origin),
r.Header.Get(headers.AccessControlRequestMethod),
r.URL.Path,
strings.Split(strings.ReplaceAll(r.Header.Get(headers.AccessControlRequestMethod), " ", ""), ","),
)

if allowance == nil {
http.Error(w, "<!-- Dear browser, please don't proceed to actually sending the request with the pair of asked method and headers for this origin and path. No worries, otherwise is still safe; it will just be ignored. Thanks for consulting. Beep boop. -->", http.StatusForbidden)
return
}

w.Header().Set(headers.AccessControlAllowCredentials, "true")
w.Header().Set(headers.AccessControlAllowMethods, strings.Join(allowance.Methods, ", "))
w.Header().Set(headers.AccessControlAllowOrigin, "*")
w.WriteHeader(http.StatusOK)
}

func (c OriginChecker) actual(w http.ResponseWriter, r *http.Request) {
allowance := matchAny(c.Allowances, r.Header.Get(headers.Origin), r.Method, r.URL.Path, slices.Collect(maps.Keys(r.Header)))

if allowance == nil {
http.Error(w, "Please try again using the official website.", http.StatusForbidden)
return
}

w.Header().Set(headers.AccessControlAllowOrigin, "*")
w.WriteHeader(http.StatusOK)
}

func isPreflight(r *http.Request) bool {
return r.Method == http.MethodOptions && has(r.Header, headers.AccessControlAllowCredentials)
}

func (c OriginChecker) Handler(w http.ResponseWriter, r *http.Request) {
if o := r.Header.Get(headers.Origin); o == "" {
w.WriteHeader(http.StatusBadRequest)
return
}

if isPreflight(r) {
c.preflight(w, r)
} else {
c.actual(w, r)
}
}

func NewOriginChecker(allow ...*Matcher) *OriginChecker {
return &OriginChecker{
Allowances: allow,
}
}
16 changes: 14 additions & 2 deletions backend/internal/web/reception/receptionist.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,17 @@ import (
"time"
)

type RequestId string
type response struct {
http.ResponseWriter
Status int
}

const ZeroRequestId = RequestId("00000000-0000-0000-0000-000000000000")
func (r *response) WriteHeader(statusCode int) {
r.Status = statusCode
r.ResponseWriter.WriteHeader(statusCode)
}

type RequestId string

type receptionist struct {
l *logger.Logger
Expand All @@ -40,6 +48,10 @@ func newReceptionist(deplcfg *deployment.Config, l *logger.Logger, handler http.
// DONE: recover
// DONE: timeout
func (recp receptionist) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.TLS != nil {
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
}

ww := &response{ResponseWriter: w}

id, err := columns.NewUuidV4[RequestId]()
Expand Down
13 changes: 0 additions & 13 deletions backend/internal/web/reception/response.go

This file was deleted.

Loading