fix: security & privacy (rate limiter fix, csrf session cookie, logout on server, drafts stale token passw, login timing, password over 72b, JWT_SECRET complexity, SMTP TLS, CSV export neutralised, fonts bundled, upload metadata, hourly cleanup job, privacy.md report)
This commit is contained in:
1 parent
e2812ee0b9
commit
ea98c5ec05
118 files changed
+3828
-130
No files matched your search
@@ -0,0 +1,39 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
const (
|
||||
EnvJWTSecret = "JWT_SECRET"
|
||||
MinJWTSecretBytes = 32
|
||||
EnvSmtpAllowInsecure = "SMTP_ALLOW_INSECURE"
|
||||
DefaultSmtpAllowInsecure = "false"
|
||||
)
|
||||
|
||||
var ErrWeakJWTSecret = errors.New("JWT_SECRET is too weak")
|
||||
|
||||
var placeholderJWTSecrets = map[string]bool{
|
||||
"change-me-to-a-long-random-string": true,
|
||||
"replace-with-output-of-openssl-rand-hex-32": true,
|
||||
}
|
||||
|
||||
func ValidateJWTSecret(secret string) error {
|
||||
if placeholderJWTSecrets[secret] {
|
||||
return fmt.Errorf("%w: it is still the example value; generate one with `openssl rand -hex 32`", ErrWeakJWTSecret)
|
||||
}
|
||||
if len(secret) < MinJWTSecretBytes {
|
||||
return fmt.Errorf("%w: it must be at least %d bytes long; generate one with `openssl rand -hex 32`", ErrWeakJWTSecret, MinJWTSecretBytes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func jwtSecretFromEnv() (string, error) {
|
||||
secret := os.Getenv(EnvJWTSecret)
|
||||
if err := ValidateJWTSecret(secret); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
@@ -9,11 +9,12 @@ import (
|
||||
)
|
||||
|
||||
type SmtpSettings struct {
|
||||
Login string
|
||||
Sender string
|
||||
Password string
|
||||
Host string
|
||||
Port int
|
||||
Login string
|
||||
Sender string
|
||||
Password string
|
||||
Host string
|
||||
Port int
|
||||
AllowInsecure bool
|
||||
}
|
||||
|
||||
type AuthRateLimitSettings struct {
|
||||
@@ -55,11 +56,21 @@ func (s Settings) IsProduction() bool {
|
||||
}
|
||||
|
||||
func NewSettingsFromEnv() (Settings, error) {
|
||||
jwtSecret, err := jwtSecretFromEnv()
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
|
||||
smtpPort, err := intEnv("SMTP_PORT")
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
|
||||
smtpAllowInsecure, err := boolEnv(EnvSmtpAllowInsecure, DefaultSmtpAllowInsecure)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
|
||||
authRateLimitMaxAttempts, err := intEnv("AUTH_RATE_LIMIT_MAX_ATTEMPTS")
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
@@ -100,18 +111,19 @@ func NewSettingsFromEnv() (Settings, error) {
|
||||
CorsAllowedOrigins: parseSemicolonSeparatedList(os.Getenv("CORS_ALLOWED_ORIGINS")),
|
||||
PublicURL: os.Getenv("PUBLIC_URL"),
|
||||
UploadDir: os.Getenv("UPLOAD_DIR"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
JWTSecret: jwtSecret,
|
||||
DBFile: os.Getenv("DB_FILE"),
|
||||
Database: database,
|
||||
Storage: storage,
|
||||
DataDirPath: os.Getenv("DATA_DIR"),
|
||||
Port: os.Getenv("PORT"),
|
||||
Smtp: SmtpSettings{
|
||||
Login: os.Getenv("SMTP_LOGIN"),
|
||||
Sender: os.Getenv("SMTP_SENDER"),
|
||||
Password: os.Getenv("SMTP_PASSWORD"),
|
||||
Host: os.Getenv("SMTP_HOST"),
|
||||
Port: smtpPort,
|
||||
Login: os.Getenv("SMTP_LOGIN"),
|
||||
Sender: os.Getenv("SMTP_SENDER"),
|
||||
Password: os.Getenv("SMTP_PASSWORD"),
|
||||
Host: os.Getenv("SMTP_HOST"),
|
||||
Port: smtpPort,
|
||||
AllowInsecure: smtpAllowInsecure,
|
||||
},
|
||||
AuthRateLimit: AuthRateLimitSettings{
|
||||
MaxAttempts: authRateLimitMaxAttempts,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package imaging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
)
|
||||
|
||||
const (
|
||||
tiffOrientationTag = 0x0112
|
||||
tiffTypeShort = 3
|
||||
tiffHeaderSize = 8
|
||||
tiffEntrySize = 12
|
||||
tiffMagic = 42
|
||||
uprightOrientation = 1
|
||||
maxOrientation = 8
|
||||
)
|
||||
|
||||
var exifHeader = []byte("Exif\x00\x00")
|
||||
|
||||
func exifOrientation(payload []byte) (uint16, bool) {
|
||||
tiff := bytes.TrimPrefix(payload, exifHeader)
|
||||
if len(tiff) < tiffHeaderSize {
|
||||
return 0, false
|
||||
}
|
||||
var order binary.ByteOrder
|
||||
switch string(tiff[:2]) {
|
||||
case "II":
|
||||
order = binary.LittleEndian
|
||||
case "MM":
|
||||
order = binary.BigEndian
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
if order.Uint16(tiff[2:]) != tiffMagic {
|
||||
return 0, false
|
||||
}
|
||||
ifd := int(order.Uint32(tiff[4:]))
|
||||
if ifd < tiffHeaderSize || ifd+2 > len(tiff) {
|
||||
return 0, false
|
||||
}
|
||||
count := int(order.Uint16(tiff[ifd:]))
|
||||
for i := range count {
|
||||
entry := ifd + 2 + i*tiffEntrySize
|
||||
if entry+tiffEntrySize > len(tiff) {
|
||||
return 0, false
|
||||
}
|
||||
if order.Uint16(tiff[entry:]) != tiffOrientationTag || order.Uint16(tiff[entry+2:]) != tiffTypeShort {
|
||||
continue
|
||||
}
|
||||
value := order.Uint16(tiff[entry+8:])
|
||||
if value < uprightOrientation || value > maxOrientation {
|
||||
return 0, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func needsOrientation(orientation uint16) bool {
|
||||
return orientation > uprightOrientation
|
||||
}
|
||||
|
||||
func orientationOnlyTIFF(orientation uint16) []byte {
|
||||
tiff := make([]byte, 0, tiffHeaderSize+2+tiffEntrySize+4)
|
||||
tiff = append(tiff, 'M', 'M')
|
||||
tiff = binary.BigEndian.AppendUint16(tiff, tiffMagic)
|
||||
tiff = binary.BigEndian.AppendUint32(tiff, tiffHeaderSize)
|
||||
tiff = binary.BigEndian.AppendUint16(tiff, 1)
|
||||
tiff = binary.BigEndian.AppendUint16(tiff, tiffOrientationTag)
|
||||
tiff = binary.BigEndian.AppendUint16(tiff, tiffTypeShort)
|
||||
tiff = binary.BigEndian.AppendUint32(tiff, 1)
|
||||
tiff = binary.BigEndian.AppendUint16(tiff, orientation)
|
||||
tiff = binary.BigEndian.AppendUint16(tiff, 0)
|
||||
return binary.BigEndian.AppendUint32(tiff, 0)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package imaging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
)
|
||||
|
||||
const (
|
||||
jpegMarkerPrefix = 0xFF
|
||||
jpegSOI = 0xD8
|
||||
jpegEOI = 0xD9
|
||||
jpegSOS = 0xDA
|
||||
jpegTEM = 0x01
|
||||
jpegRST0 = 0xD0
|
||||
jpegRST7 = 0xD7
|
||||
jpegAPP0 = 0xE0
|
||||
jpegAPP1 = 0xE1
|
||||
jpegAPP2 = 0xE2
|
||||
jpegAPP14 = 0xEE
|
||||
jpegAPP15 = 0xEF
|
||||
jpegCOM = 0xFE
|
||||
jpegStuffedZero = 0x00
|
||||
)
|
||||
|
||||
var iccProfileSignature = []byte("ICC_PROFILE\x00")
|
||||
|
||||
type jpegSegment struct {
|
||||
marker byte
|
||||
payload []byte
|
||||
}
|
||||
|
||||
func stripJPEG(data []byte) ([]byte, error) {
|
||||
if len(data) < 4 || data[0] != jpegMarkerPrefix || data[1] != jpegSOI {
|
||||
return nil, errMalformedContainer
|
||||
}
|
||||
|
||||
out := []byte{jpegMarkerPrefix, jpegSOI}
|
||||
insertAt := len(out)
|
||||
orientation := uint16(uprightOrientation)
|
||||
first := true
|
||||
|
||||
for pos := 2; ; {
|
||||
marker, next, err := readJPEGMarker(data, pos)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pos = next
|
||||
|
||||
if marker == jpegEOI {
|
||||
out = append(out, jpegMarkerPrefix, jpegEOI)
|
||||
return withOrientationSegment(out, insertAt, orientation), nil
|
||||
}
|
||||
if isStandaloneJPEGMarker(marker) {
|
||||
out = append(out, jpegMarkerPrefix, marker)
|
||||
continue
|
||||
}
|
||||
|
||||
segment, next, err := readJPEGSegment(data, pos, marker)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pos = next
|
||||
|
||||
if isJPEGMetadata(segment) {
|
||||
if found, ok := jpegExifOrientation(segment); ok {
|
||||
orientation = found
|
||||
}
|
||||
} else {
|
||||
out = appendJPEGSegment(out, segment)
|
||||
if first && segment.marker == jpegAPP0 {
|
||||
insertAt = len(out)
|
||||
}
|
||||
}
|
||||
first = false
|
||||
|
||||
if marker == jpegSOS {
|
||||
end := jpegScanEnd(data, pos)
|
||||
out = append(out, data[pos:end]...)
|
||||
if end == len(data) {
|
||||
return withOrientationSegment(out, insertAt, orientation), nil
|
||||
}
|
||||
pos = end
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readJPEGMarker(data []byte, pos int) (byte, int, error) {
|
||||
if pos >= len(data) || data[pos] != jpegMarkerPrefix {
|
||||
return 0, 0, errMalformedContainer
|
||||
}
|
||||
for pos < len(data) && data[pos] == jpegMarkerPrefix {
|
||||
pos++
|
||||
}
|
||||
if pos >= len(data) {
|
||||
return 0, 0, errMalformedContainer
|
||||
}
|
||||
return data[pos], pos + 1, nil
|
||||
}
|
||||
|
||||
func readJPEGSegment(data []byte, pos int, marker byte) (jpegSegment, int, error) {
|
||||
if pos+2 > len(data) {
|
||||
return jpegSegment{}, 0, errMalformedContainer
|
||||
}
|
||||
length := int(binary.BigEndian.Uint16(data[pos:]))
|
||||
if length < 2 || pos+length > len(data) {
|
||||
return jpegSegment{}, 0, errMalformedContainer
|
||||
}
|
||||
return jpegSegment{marker: marker, payload: data[pos+2 : pos+length]}, pos + length, nil
|
||||
}
|
||||
|
||||
func jpegScanEnd(data []byte, pos int) int {
|
||||
for pos < len(data) {
|
||||
if data[pos] != jpegMarkerPrefix || pos+1 >= len(data) {
|
||||
pos++
|
||||
continue
|
||||
}
|
||||
next := data[pos+1]
|
||||
if next == jpegStuffedZero || isRestartMarker(next) {
|
||||
pos += 2
|
||||
continue
|
||||
}
|
||||
return pos
|
||||
}
|
||||
return len(data)
|
||||
}
|
||||
|
||||
func isStandaloneJPEGMarker(marker byte) bool {
|
||||
return marker == jpegTEM || isRestartMarker(marker)
|
||||
}
|
||||
|
||||
func isRestartMarker(marker byte) bool {
|
||||
return marker >= jpegRST0 && marker <= jpegRST7
|
||||
}
|
||||
|
||||
func isJPEGMetadata(segment jpegSegment) bool {
|
||||
if segment.marker == jpegCOM {
|
||||
return true
|
||||
}
|
||||
if segment.marker == jpegAPP2 {
|
||||
return !bytes.HasPrefix(segment.payload, iccProfileSignature)
|
||||
}
|
||||
if segment.marker == jpegAPP0 || segment.marker == jpegAPP14 {
|
||||
return false
|
||||
}
|
||||
return segment.marker >= jpegAPP1 && segment.marker <= jpegAPP15
|
||||
}
|
||||
|
||||
func jpegExifOrientation(segment jpegSegment) (uint16, bool) {
|
||||
if segment.marker != jpegAPP1 || !bytes.HasPrefix(segment.payload, exifHeader) {
|
||||
return 0, false
|
||||
}
|
||||
return exifOrientation(segment.payload)
|
||||
}
|
||||
|
||||
func appendJPEGSegment(out []byte, segment jpegSegment) []byte {
|
||||
out = append(out, jpegMarkerPrefix, segment.marker)
|
||||
out = binary.BigEndian.AppendUint16(out, uint16(len(segment.payload)+2))
|
||||
return append(out, segment.payload...)
|
||||
}
|
||||
|
||||
func withOrientationSegment(out []byte, insertAt int, orientation uint16) []byte {
|
||||
if !needsOrientation(orientation) {
|
||||
return out
|
||||
}
|
||||
payload := append(bytes.Clone(exifHeader), orientationOnlyTIFF(orientation)...)
|
||||
segment := appendJPEGSegment(nil, jpegSegment{marker: jpegAPP1, payload: payload})
|
||||
return append(out[:insertAt:insertAt], append(segment, out[insertAt:]...)...)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package imaging
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/image"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var errMalformedContainer = errors.New("malformed image container")
|
||||
|
||||
type MetadataStripper struct{}
|
||||
|
||||
var _ domain.MetadataStripper = &MetadataStripper{}
|
||||
|
||||
func NewMetadataStripper() *MetadataStripper {
|
||||
return &MetadataStripper{}
|
||||
}
|
||||
|
||||
func (s *MetadataStripper) Strip(contentType string, data []byte) ([]byte, error) {
|
||||
var stripped []byte
|
||||
var err error
|
||||
switch contentType {
|
||||
case "image/jpeg":
|
||||
stripped, err = stripJPEG(data)
|
||||
case "image/png":
|
||||
stripped, err = stripPNG(data)
|
||||
case "image/webp":
|
||||
stripped, err = stripWebP(data)
|
||||
default:
|
||||
return data, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.Join(domain.ErrImageUnreadable, err)
|
||||
}
|
||||
return stripped, nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package imaging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"hash/crc32"
|
||||
)
|
||||
|
||||
const (
|
||||
pngChunkHeaderSize = 8
|
||||
pngChunkCRCSize = 4
|
||||
pngIHDR = "IHDR"
|
||||
pngIEND = "IEND"
|
||||
pngEXIF = "eXIf"
|
||||
)
|
||||
|
||||
var pngSignature = []byte("\x89PNG\r\n\x1a\n")
|
||||
|
||||
var pngMetadataChunks = map[string]bool{
|
||||
pngEXIF: true,
|
||||
"tEXt": true,
|
||||
"zTXt": true,
|
||||
"iTXt": true,
|
||||
"tIME": true,
|
||||
}
|
||||
|
||||
func stripPNG(data []byte) ([]byte, error) {
|
||||
if !bytes.HasPrefix(data, pngSignature) {
|
||||
return nil, errMalformedContainer
|
||||
}
|
||||
|
||||
out := bytes.Clone(pngSignature)
|
||||
orientation := uint16(uprightOrientation)
|
||||
insertAt := 0
|
||||
|
||||
for pos := len(pngSignature); pos < len(data); {
|
||||
available := len(data) - pos - pngChunkHeaderSize - pngChunkCRCSize
|
||||
if available < 0 {
|
||||
return nil, errMalformedContainer
|
||||
}
|
||||
size := binary.BigEndian.Uint32(data[pos:])
|
||||
if uint64(size) > uint64(available) {
|
||||
return nil, errMalformedContainer
|
||||
}
|
||||
kind := string(data[pos+4 : pos+pngChunkHeaderSize])
|
||||
payload := data[pos+pngChunkHeaderSize : pos+pngChunkHeaderSize+int(size)]
|
||||
end := pos + pngChunkHeaderSize + len(payload) + pngChunkCRCSize
|
||||
|
||||
if pngMetadataChunks[kind] {
|
||||
if kind == pngEXIF {
|
||||
if found, ok := exifOrientation(payload); ok {
|
||||
orientation = found
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out = append(out, data[pos:end]...)
|
||||
if kind == pngIHDR {
|
||||
insertAt = len(out)
|
||||
}
|
||||
}
|
||||
pos = end
|
||||
|
||||
if kind == pngIEND {
|
||||
return withPNGOrientation(out, insertAt, orientation)
|
||||
}
|
||||
}
|
||||
return nil, errMalformedContainer
|
||||
}
|
||||
|
||||
func withPNGOrientation(out []byte, insertAt int, orientation uint16) ([]byte, error) {
|
||||
if !needsOrientation(orientation) {
|
||||
return out, nil
|
||||
}
|
||||
if insertAt == 0 {
|
||||
return nil, errMalformedContainer
|
||||
}
|
||||
chunk := pngChunk(pngEXIF, orientationOnlyTIFF(orientation))
|
||||
return append(out[:insertAt:insertAt], append(chunk, out[insertAt:]...)...), nil
|
||||
}
|
||||
|
||||
func pngChunk(kind string, payload []byte) []byte {
|
||||
chunk := binary.BigEndian.AppendUint32(nil, uint32(len(payload)))
|
||||
chunk = append(chunk, kind...)
|
||||
chunk = append(chunk, payload...)
|
||||
return binary.BigEndian.AppendUint32(chunk, crc32.ChecksumIEEE(chunk[4:]))
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package imaging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
)
|
||||
|
||||
const (
|
||||
riffHeaderSize = 12
|
||||
riffChunkHeaderSize = 8
|
||||
webpVP8X = "VP8X"
|
||||
webpEXIF = "EXIF"
|
||||
webpXMP = "XMP "
|
||||
webpEXIFFlag = 0x08
|
||||
webpXMPFlag = 0x04
|
||||
)
|
||||
|
||||
func stripWebP(data []byte) ([]byte, error) {
|
||||
if len(data) < riffHeaderSize || string(data[:4]) != "RIFF" || string(data[8:12]) != "WEBP" {
|
||||
return nil, errMalformedContainer
|
||||
}
|
||||
declared := int(binary.LittleEndian.Uint32(data[4:])) + riffChunkHeaderSize
|
||||
if declared > len(data) || declared < riffHeaderSize {
|
||||
return nil, errMalformedContainer
|
||||
}
|
||||
data = data[:declared]
|
||||
|
||||
var chunks [][]byte
|
||||
orientation := uint16(uprightOrientation)
|
||||
for pos := riffHeaderSize; pos < len(data); {
|
||||
available := len(data) - pos - riffChunkHeaderSize
|
||||
if available < 0 {
|
||||
return nil, errMalformedContainer
|
||||
}
|
||||
size := binary.LittleEndian.Uint32(data[pos+4:])
|
||||
padded := uint64(size) + uint64(size%2)
|
||||
if padded > uint64(available) {
|
||||
return nil, errMalformedContainer
|
||||
}
|
||||
kind := string(data[pos : pos+4])
|
||||
payload := data[pos+riffChunkHeaderSize : pos+riffChunkHeaderSize+int(size)]
|
||||
end := pos + riffChunkHeaderSize + int(padded)
|
||||
switch kind {
|
||||
case webpEXIF:
|
||||
if found, ok := exifOrientation(payload); ok {
|
||||
orientation = found
|
||||
}
|
||||
case webpXMP:
|
||||
default:
|
||||
chunks = append(chunks, bytes.Clone(data[pos:end]))
|
||||
}
|
||||
pos = end
|
||||
}
|
||||
|
||||
if len(chunks) == 0 {
|
||||
return nil, errMalformedContainer
|
||||
}
|
||||
if string(chunks[0][:4]) == webpVP8X {
|
||||
if len(chunks[0]) <= riffChunkHeaderSize {
|
||||
return nil, errMalformedContainer
|
||||
}
|
||||
flags := &chunks[0][riffChunkHeaderSize]
|
||||
*flags &^= webpEXIFFlag | webpXMPFlag
|
||||
if needsOrientation(orientation) {
|
||||
*flags |= webpEXIFFlag
|
||||
chunks = append(chunks, riffChunk(webpEXIF, orientationOnlyTIFF(orientation)))
|
||||
}
|
||||
}
|
||||
return riffContainer(chunks), nil
|
||||
}
|
||||
|
||||
func riffChunk(kind string, payload []byte) []byte {
|
||||
chunk := append([]byte(kind), binary.LittleEndian.AppendUint32(nil, uint32(len(payload)))...)
|
||||
chunk = append(chunk, payload...)
|
||||
if len(payload)%2 == 1 {
|
||||
chunk = append(chunk, 0)
|
||||
}
|
||||
return chunk
|
||||
}
|
||||
|
||||
func riffContainer(chunks [][]byte) []byte {
|
||||
body := []byte("WEBP")
|
||||
for _, chunk := range chunks {
|
||||
body = append(body, chunk...)
|
||||
}
|
||||
out := append([]byte("RIFF"), binary.LittleEndian.AppendUint32(nil, uint32(len(body)))...)
|
||||
return append(out, body...)
|
||||
}
|
||||
@@ -81,17 +81,27 @@ func (t transport) connect() (*smtp.Client, error) {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
var errTLSUnavailable = errors.New("smtp server does not offer STARTTLS; set SMTP_ALLOW_INSECURE=true to send without encryption")
|
||||
|
||||
func (t transport) negotiate(client *smtp.Client) error {
|
||||
if t.settings.Port != implicitTLSPort {
|
||||
if supported, _ := client.Extension("STARTTLS"); supported {
|
||||
if err := client.StartTLS(t.tlsConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := t.startTLS(client); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return t.authenticate(client)
|
||||
}
|
||||
|
||||
func (t transport) startTLS(client *smtp.Client) error {
|
||||
if supported, _ := client.Extension("STARTTLS"); supported {
|
||||
return client.StartTLS(t.tlsConfig)
|
||||
}
|
||||
if t.settings.AllowInsecure || isLoopback(t.settings.Host) {
|
||||
return nil
|
||||
}
|
||||
return errTLSUnavailable
|
||||
}
|
||||
|
||||
func (t transport) authenticate(client *smtp.Client) error {
|
||||
if t.settings.Login == "" {
|
||||
return nil
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func Migrate(db *gorm.DB, publicURL string) error {
|
||||
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{})
|
||||
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{}, &models.RevokedSession{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/session"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
)
|
||||
|
||||
func RevokedSessionToModel(r session.Revocation) models.RevokedSession {
|
||||
return models.RevokedSession{ID: r.ID, ExpiresAt: r.ExpiresAt.UTC()}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type RevokedSession struct {
|
||||
ID string `gorm:"primaryKey;size:64"`
|
||||
ExpiresAt time.Time `gorm:"index;not null"`
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
domainSession "RenewCMS/internal/domain/session"
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type RevokedSessionRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRevokedSessionRepository(db *gorm.DB) *RevokedSessionRepository {
|
||||
return &RevokedSessionRepository{db}
|
||||
}
|
||||
|
||||
func (s *RevokedSessionRepository) Revoke(revocation domainSession.Revocation) error {
|
||||
model := mappers.RevokedSessionToModel(revocation)
|
||||
return s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&model).Error
|
||||
}
|
||||
|
||||
func (s *RevokedSessionRepository) IsRevoked(id string) (bool, error) {
|
||||
var count int64
|
||||
if err := s.db.Model(&entity.RevokedSession{}).Where("id = ?", id).Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (s *RevokedSessionRepository) DeleteExpired(now time.Time) (int, error) {
|
||||
result := s.db.Where("expires_at <= ?", now.UTC()).Delete(&entity.RevokedSession{})
|
||||
return int(result.RowsAffected), result.Error
|
||||
}
|
||||
|
||||
var _ domainSession.Repository = &RevokedSessionRepository{}
|
||||
@@ -3,6 +3,7 @@ package scheduler
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/purge"
|
||||
"RenewCMS/internal/domain/retention"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -23,6 +24,10 @@ type CleanupJob interface {
|
||||
Tick(now time.Time) (audit.CleanupResult, bool, error)
|
||||
}
|
||||
|
||||
type RetentionJob interface {
|
||||
Tick(now time.Time) (retention.Result, error)
|
||||
}
|
||||
|
||||
type task interface {
|
||||
tick(ctx context.Context, now time.Time) (summary string, ran bool, err error)
|
||||
}
|
||||
@@ -49,6 +54,16 @@ func (c cleanupTask) tick(_ context.Context, now time.Time) (string, bool, error
|
||||
return fmt.Sprintf("removed=%d", result.Removed), ran, err
|
||||
}
|
||||
|
||||
type retentionTask struct {
|
||||
job RetentionJob
|
||||
}
|
||||
|
||||
func (r retentionTask) tick(_ context.Context, now time.Time) (string, bool, error) {
|
||||
result, err := r.job.Tick(now)
|
||||
summary := fmt.Sprintf("revoked_sessions=%d unverified_users=%d invites=%d", result.RevokedSessions, result.UnverifiedUsers, result.Invites)
|
||||
return summary, result.Total() > 0, err
|
||||
}
|
||||
|
||||
type Runner struct {
|
||||
label string
|
||||
task task
|
||||
@@ -60,8 +75,9 @@ type Runner struct {
|
||||
}
|
||||
|
||||
const (
|
||||
purgeLabel = "automatic purge"
|
||||
cleanupLabel = "activity cleanup"
|
||||
purgeLabel = "automatic purge"
|
||||
cleanupLabel = "activity cleanup"
|
||||
retentionLabel = "personal data retention"
|
||||
)
|
||||
|
||||
func newRunner(label string, task task, now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time, logger *log.Logger) *Runner {
|
||||
@@ -76,6 +92,16 @@ func NewCleanupRunner(job CleanupJob, now func() time.Time, ticks <-chan time.Ti
|
||||
return newRunner(cleanupLabel, cleanupTask{job}, now, ticks, startup, logger)
|
||||
}
|
||||
|
||||
func NewRetentionRunner(job RetentionJob, now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time, logger *log.Logger) *Runner {
|
||||
return newRunner(retentionLabel, retentionTask{job}, now, ticks, startup, logger)
|
||||
}
|
||||
|
||||
func NewSystemRetentionRunner(job RetentionJob, logger *log.Logger) *Runner {
|
||||
return withSystemTimers(func(now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time) *Runner {
|
||||
return NewRetentionRunner(job, now, ticks, startup, logger)
|
||||
})
|
||||
}
|
||||
|
||||
func NewSystemRunner(job Job, logger *log.Logger) *Runner {
|
||||
return withSystemTimers(func(now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time) *Runner {
|
||||
return NewRunner(job, now, ticks, startup, logger)
|
||||
|
||||
@@ -66,6 +66,10 @@ func (a *AcceptInviteUseCase) accept(command AcceptInviteCommand, pending invite
|
||||
return user.User{}, err
|
||||
}
|
||||
|
||||
if err := user.ValidatePassword(command.Password); err != nil {
|
||||
return user.User{}, err
|
||||
}
|
||||
|
||||
hashedPassword, err := a.hasher.Hash(command.Password)
|
||||
if err != nil {
|
||||
return user.User{}, err
|
||||
|
||||
@@ -5,17 +5,21 @@ import (
|
||||
"RenewCMS/internal/domain/hash"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type AuthenticateUserUseCase struct {
|
||||
userRepository user.Repository
|
||||
hasher hash.Hasher
|
||||
recorder audit.Recorder
|
||||
decoyOnce sync.Once
|
||||
decoyHash string
|
||||
}
|
||||
|
||||
func NewAuthenticateUserUseCase(userRepository user.Repository, hasher hash.Hasher, recorder audit.Recorder) *AuthenticateUserUseCase {
|
||||
return &AuthenticateUserUseCase{userRepository, hasher, recorder}
|
||||
return &AuthenticateUserUseCase{userRepository: userRepository, hasher: hasher, recorder: recorder}
|
||||
}
|
||||
|
||||
func (a *AuthenticateUserUseCase) Authenticate(ctx context.Context, username string, password string) (user.User, error) {
|
||||
@@ -34,6 +38,9 @@ func (a *AuthenticateUserUseCase) Authenticate(ctx context.Context, username str
|
||||
func (a *AuthenticateUserUseCase) verifyCredentials(username string, password string) (user.User, error) {
|
||||
dbUser, err := a.userRepository.GetByUsername(username)
|
||||
if err != nil {
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
a.compareWithDecoy(password)
|
||||
}
|
||||
return user.User{}, err
|
||||
}
|
||||
|
||||
@@ -44,6 +51,15 @@ func (a *AuthenticateUserUseCase) verifyCredentials(username string, password st
|
||||
return dbUser, nil
|
||||
}
|
||||
|
||||
func (a *AuthenticateUserUseCase) compareWithDecoy(password string) {
|
||||
a.decoyOnce.Do(func() {
|
||||
a.decoyHash, _ = a.hasher.Hash(rand.Text())
|
||||
})
|
||||
if a.decoyHash != "" {
|
||||
_ = a.hasher.Compare(a.decoyHash, password)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AuthenticateUserUseCase) recordRejectedLogin(ctx context.Context, username string, cause error) {
|
||||
if !errors.Is(cause, user.ErrUserNotFound) && !errors.Is(cause, user.ErrInvalidCredentials) {
|
||||
return
|
||||
|
||||
@@ -29,10 +29,11 @@ type CreateImageUseCase struct {
|
||||
imageRepository image.Repository
|
||||
storage image.Storage
|
||||
recorder audit.Recorder
|
||||
stripper image.MetadataStripper
|
||||
}
|
||||
|
||||
func NewCreateImageUseCase(imageRepository image.Repository, storage image.Storage, recorder audit.Recorder) *CreateImageUseCase {
|
||||
return &CreateImageUseCase{imageRepository, storage, recorder}
|
||||
func NewCreateImageUseCase(imageRepository image.Repository, storage image.Storage, recorder audit.Recorder, stripper image.MetadataStripper) *CreateImageUseCase {
|
||||
return &CreateImageUseCase{imageRepository, storage, recorder, stripper}
|
||||
}
|
||||
|
||||
func (g *CreateImageUseCase) CreateImage(ctx context.Context, input image.ImageInput) (image.Image, error) {
|
||||
@@ -74,6 +75,11 @@ func (g *CreateImageUseCase) create(input image.ImageInput) (image.Image, error)
|
||||
return image.Image{}, err
|
||||
}
|
||||
|
||||
data, err = g.stripper.Strip(input.ContentType, data)
|
||||
if err != nil {
|
||||
return image.Image{}, err
|
||||
}
|
||||
|
||||
checksum := sha256.Sum256(data)
|
||||
storedName, err := g.storage.Put(input.ContentType, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
|
||||
@@ -55,6 +55,10 @@ func (g *CreateUserUseCase) create(createUser CreateUserCommand) (user.User, err
|
||||
return user.User{}, user.ErrRegistrationClosed
|
||||
}
|
||||
|
||||
if err := user.ValidatePassword(createUser.Password); err != nil {
|
||||
return user.User{}, err
|
||||
}
|
||||
|
||||
rawUuid := uuid.NewString()
|
||||
|
||||
hashedPassword, err := g.hasher.Hash(createUser.Password)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/invite"
|
||||
"RenewCMS/internal/domain/retention"
|
||||
"RenewCMS/internal/domain/session"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"time"
|
||||
)
|
||||
|
||||
type EnforceRetentionUseCase struct {
|
||||
users user.Repository
|
||||
invites invite.Repository
|
||||
sessions session.Repository
|
||||
}
|
||||
|
||||
func NewEnforceRetentionUseCase(users user.Repository, invites invite.Repository, sessions session.Repository) *EnforceRetentionUseCase {
|
||||
return &EnforceRetentionUseCase{users, invites, sessions}
|
||||
}
|
||||
|
||||
func (e *EnforceRetentionUseCase) Tick(now time.Time) (retention.Result, error) {
|
||||
var result retention.Result
|
||||
var err error
|
||||
|
||||
if result.RevokedSessions, err = e.sessions.DeleteExpired(now); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if result.UnverifiedUsers, err = e.deleteStaleUnverifiedUsers(now); err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Invites, err = e.deleteStaleInvites(now)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (e *EnforceRetentionUseCase) deleteStaleUnverifiedUsers(now time.Time) (int, error) {
|
||||
removed := 0
|
||||
for _, candidate := range e.users.GetAll() {
|
||||
if !candidate.IsStaleUnverifiedAt(now) {
|
||||
continue
|
||||
}
|
||||
if err := e.users.Delete(candidate.ID); err != nil {
|
||||
return removed, err
|
||||
}
|
||||
removed++
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
func (e *EnforceRetentionUseCase) deleteStaleInvites(now time.Time) (int, error) {
|
||||
removed := 0
|
||||
for _, candidate := range e.invites.GetAll() {
|
||||
if !candidate.IsStaleAt(now) {
|
||||
continue
|
||||
}
|
||||
if err := e.invites.Delete(candidate.ID); err != nil {
|
||||
return removed, err
|
||||
}
|
||||
removed++
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
const exportBatchSize = 500
|
||||
|
||||
const formulaGuard = "'"
|
||||
const formulaStarts = "=+-@"
|
||||
const formulaStarts = "=+-@\t\r"
|
||||
|
||||
var exportHeader = []string{"when", "actor", "action", "target", "outcome", "source", "ip"}
|
||||
|
||||
|
||||
@@ -35,6 +35,10 @@ func (r *ResetPasswordUseCase) ResetPassword(ctx context.Context, email string,
|
||||
return user.ErrPasswordResetCodeExpired
|
||||
}
|
||||
|
||||
if err := user.ValidatePassword(newPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hashedPassword, err := r.hasher.Hash(newPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/session"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SessionRevocationUseCase struct {
|
||||
sessions session.Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewSessionRevocationUseCase(sessions session.Repository, now func() time.Time) *SessionRevocationUseCase {
|
||||
return &SessionRevocationUseCase{sessions, now}
|
||||
}
|
||||
|
||||
func (s *SessionRevocationUseCase) Revoke(id string, expiresAt time.Time) error {
|
||||
if err := session.ValidateID(id); err != nil {
|
||||
return err
|
||||
}
|
||||
if !expiresAt.After(s.now()) {
|
||||
return nil
|
||||
}
|
||||
return s.sessions.Revoke(session.Revocation{ID: id, ExpiresAt: expiresAt})
|
||||
}
|
||||
|
||||
func (s *SessionRevocationUseCase) IsRevoked(id string) (bool, error) {
|
||||
if err := session.ValidateID(id); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return s.sessions.IsRevoked(id)
|
||||
}
|
||||
@@ -44,6 +44,7 @@ var failureReasons = []failureReason{
|
||||
{user.ErrVerificationCodeExpired, "The verification code has expired."},
|
||||
{user.ErrInvalidDisplayName, "That display name is too long."},
|
||||
{user.ErrUsernameTaken, "That username is already taken."},
|
||||
{user.ErrPasswordTooLong, "The password is longer than 72 bytes."},
|
||||
{invite.ErrEmailHasAccount, "That email already has an account."},
|
||||
{invite.ErrPendingInvite, "An invitation is already pending for that email."},
|
||||
{invite.ErrInviteEmailNotSent, "The invitation email could not be sent."},
|
||||
|
||||
Reference in new issue
Block a user