From b1cd985720ab703742a7511d226b15aa9a2e4f9d Mon Sep 17 00:00:00 2001 From: astelm Date: Mon, 5 Oct 2026 22:36:24 +0300 Subject: [PATCH] release: 1.1.1 - session auth and security improvements --- Makefile | 126 +++- config.go | 372 ++++++++-- config.yaml | 9 +- main.go | 1 - web/app.js | 511 +++++++++---- web/index.html | 53 +- web/login.html | 101 +++ web_server.go | 1905 ++++++++++++++++++++++++++++++++++++++++-------- 8 files changed, 2539 insertions(+), 539 deletions(-) create mode 100644 web/login.html diff --git a/Makefile b/Makefile index a5602dc..bd563a9 100644 --- a/Makefile +++ b/Makefile @@ -1,31 +1,40 @@ APP_NAME := ric930-fake-smtp +VERSION ?= 1.1.0 + +GO := go BUILD_DIR := builds LINUX_DIR := $(BUILD_DIR)/linux WINDOWS_DIR := $(BUILD_DIR)/windows +RELEASE_DIR := $(BUILD_DIR)/releases LINUX_BIN := $(LINUX_DIR)/$(APP_NAME) WINDOWS_BIN := $(WINDOWS_DIR)/$(APP_NAME).exe -GO := go +LINUX_ARCHIVE := $(RELEASE_DIR)/$(APP_NAME)_$(VERSION)_linux_amd64.tar.gz +WINDOWS_ARCHIVE := $(RELEASE_DIR)/$(APP_NAME)_$(VERSION)_windows_amd64.zip BUILD_FLAGS := -trimpath LDFLAGS := -s -w + .PHONY: all .PHONY: build .PHONY: build-all .PHONY: build-linux .PHONY: build-windows +.PHONY: release +.PHONY: package-linux +.PHONY: package-windows .PHONY: clean .PHONY: fmt .PHONY: tidy .PHONY: check -# ------------------------------------------------------------ -# Host OS -# ------------------------------------------------------------ +# ============================================================ +# Host OS helpers +# ============================================================ ifeq ($(OS),Windows_NT) @@ -68,9 +77,9 @@ endef endif -# ------------------------------------------------------------ -# Default -# ------------------------------------------------------------ +# ============================================================ +# Default targets +# ============================================================ all: build-all @@ -79,9 +88,9 @@ build: build-all build-all: build-linux build-windows -# ------------------------------------------------------------ -# Linux -# ------------------------------------------------------------ +# ============================================================ +# Linux build +# ============================================================ build-linux: @echo "==> Building Linux amd64" @@ -93,12 +102,20 @@ ifeq ($(OS),Windows_NT) @set "CGO_ENABLED=0" && \ set "GOOS=linux" && \ set "GOARCH=amd64" && \ - $(GO) build $(BUILD_FLAGS) -ldflags="$(LDFLAGS)" -o "$(subst /,\,$(LINUX_BIN))" . + $(GO) build \ + $(BUILD_FLAGS) \ + -ldflags="$(LDFLAGS)" \ + -o "$(subst /,\,$(LINUX_BIN))" \ + . else @CGO_ENABLED=0 \ GOOS=linux \ GOARCH=amd64 \ - $(GO) build $(BUILD_FLAGS) -ldflags="$(LDFLAGS)" -o "$(LINUX_BIN)" . + $(GO) build \ + $(BUILD_FLAGS) \ + -ldflags="$(LDFLAGS)" \ + -o "$(LINUX_BIN)" \ + . endif $(call copy_file,config.yaml,$(LINUX_DIR)/config.yaml) @@ -107,9 +124,9 @@ endif @echo "==> Linux build created: $(LINUX_DIR)" -# ------------------------------------------------------------ -# Windows -# ------------------------------------------------------------ +# ============================================================ +# Windows build +# ============================================================ build-windows: @echo "==> Building Windows amd64" @@ -121,12 +138,20 @@ ifeq ($(OS),Windows_NT) @set "CGO_ENABLED=0" && \ set "GOOS=windows" && \ set "GOARCH=amd64" && \ - $(GO) build $(BUILD_FLAGS) -ldflags="$(LDFLAGS)" -o "$(subst /,\,$(WINDOWS_BIN))" . + $(GO) build \ + $(BUILD_FLAGS) \ + -ldflags="$(LDFLAGS)" \ + -o "$(subst /,\,$(WINDOWS_BIN))" \ + . else @CGO_ENABLED=0 \ GOOS=windows \ GOARCH=amd64 \ - $(GO) build $(BUILD_FLAGS) -ldflags="$(LDFLAGS)" -o "$(WINDOWS_BIN)" . + $(GO) build \ + $(BUILD_FLAGS) \ + -ldflags="$(LDFLAGS)" \ + -o "$(WINDOWS_BIN)" \ + . endif $(call copy_file,config.yaml,$(WINDOWS_DIR)/config.yaml) @@ -135,12 +160,71 @@ endif @echo "==> Windows build created: $(WINDOWS_DIR)" -# ------------------------------------------------------------ +# ============================================================ +# Release +# ============================================================ + +release: build-all package-linux package-windows + @echo "" + @echo "==> Release $(VERSION) created" + @echo " Linux: $(LINUX_ARCHIVE)" + @echo " Windows: $(WINDOWS_ARCHIVE)" + + +# ============================================================ +# Linux package +# ============================================================ + +package-linux: + @echo "==> Creating Linux release archive" + $(call make_dir,$(RELEASE_DIR)) + +ifeq ($(OS),Windows_NT) + @tar \ + -C "$(subst /,\,$(LINUX_DIR))" \ + -czf "$(subst /,\,$(LINUX_ARCHIVE))" \ + . +else + @tar \ + -C "$(LINUX_DIR)" \ + -czf "$(LINUX_ARCHIVE)" \ + . +endif + + @echo "==> Linux archive created: $(LINUX_ARCHIVE)" + + +# ============================================================ +# Windows package +# ============================================================ + +package-windows: + @echo "==> Creating Windows release archive" + $(call make_dir,$(RELEASE_DIR)) + +ifeq ($(OS),Windows_NT) + @powershell -NoProfile -Command \ + "Compress-Archive \ + -Path '$(subst /,\,$(WINDOWS_DIR))\*' \ + -DestinationPath '$(subst /,\,$(WINDOWS_ARCHIVE))' \ + -Force" +else + @rm -f "$(WINDOWS_ARCHIVE)" + @cd "$(WINDOWS_DIR)" && \ + zip -qr \ + "../releases/$(APP_NAME)_$(VERSION)_windows_amd64.zip" \ + . +endif + + @echo "==> Windows archive created: $(WINDOWS_ARCHIVE)" + + +# ============================================================ # Maintenance -# ------------------------------------------------------------ +# ============================================================ clean: - @echo "==> Cleaning builds" + @echo "==> Cleaning build directory" $(call remove_dir,$(BUILD_DIR)) @@ -155,7 +239,7 @@ tidy: check: - @echo "==> Checking project" + @echo "==> Running Go checks" @$(GO) vet ./... @$(GO) test ./... diff --git a/config.go b/config.go index 81fb50d..18f8d82 100644 --- a/config.go +++ b/config.go @@ -1,10 +1,12 @@ package main import ( + "bytes" "fmt" "log" "os" "path/filepath" + "time" "gopkg.in/yaml.v3" ) @@ -24,16 +26,52 @@ type SMTPConfig struct { } type WebConfig struct { - Enabled bool `yaml:"enabled"` - Port int `yaml:"port"` - Directory string `yaml:"directory"` - BasicAuth BasicAuthConfig `yaml:"basic_auth"` + Enabled bool `yaml:"enabled"` + Port int `yaml:"port"` + Directory string `yaml:"directory"` + Auth AuthConfig `yaml:"auth"` } -type BasicAuthConfig struct { - Enabled bool `yaml:"enabled"` - Username string `yaml:"username"` - Password string `yaml:"password"` +type AuthConfig struct { + Enabled bool `yaml:"enabled"` + Username string `yaml:"username"` + Password string `yaml:"password"` + SessionTimeout string `yaml:"session_timeout"` + CookieSecure bool `yaml:"cookie_secure"` + + LoginMaxAttempts int `yaml:"login_max_attempts"` + LoginWindow string `yaml:"login_window"` + LoginLockout string `yaml:"login_lockout"` +} + +func defaultConfig() *Config { + return &Config{ + SMTP: SMTPConfig{ + ListenAddress: "0.0.0.0", + ListenPort: 25, + StorageDir: "emails", + DomainName: "m.ric930.ru", + MaxMessageSize: 50 * 1024 * 1024, + EnableAuth: true, + }, + + Web: WebConfig{ + Enabled: true, + Port: 8089, + Directory: "web", + + Auth: AuthConfig{ + Enabled: true, + Username: "admin", + Password: "admin", + SessionTimeout: "15m", + CookieSecure: false, + LoginMaxAttempts: 3, + LoginWindow: "5m", + LoginLockout: "15m", + }, + }, + } } func getExecutableDir() (string, error) { @@ -42,138 +80,308 @@ func getExecutableDir() (string, error) { return "", err } + exe, err = filepath.Abs(exe) + if err != nil { + return "", err + } + return filepath.Dir(exe), nil } -func defaultConfig() *Config { - return &Config{ - SMTP: SMTPConfig{ - ListenAddress: "0.0.0.0", - ListenPort: 1025, - StorageDir: "emails", - DomainName: "m.ric930.ru", - MaxMessageSize: 50 * 1024 * 1024, - EnableAuth: true, - }, - Web: WebConfig{ - Enabled: true, - Port: 8089, - Directory: "web", - BasicAuth: BasicAuthConfig{ - Enabled: true, - Username: "admin", - Password: "admin", - }, - }, - } -} +func resolveConfigPaths( + cfg *Config, + exeDir string, +) { + if cfg.SMTP.StorageDir != "" && + !filepath.IsAbs(cfg.SMTP.StorageDir) { -func resolveConfigPaths(cfg *Config, exeDir string) { - if !filepath.IsAbs(cfg.SMTP.StorageDir) { - cfg.SMTP.StorageDir = filepath.Join(exeDir, cfg.SMTP.StorageDir) + cfg.SMTP.StorageDir = filepath.Join( + exeDir, + cfg.SMTP.StorageDir, + ) } - if !filepath.IsAbs(cfg.Web.Directory) { - cfg.Web.Directory = filepath.Join(exeDir, cfg.Web.Directory) + if cfg.Web.Directory != "" && + !filepath.IsAbs(cfg.Web.Directory) { + + cfg.Web.Directory = filepath.Join( + exeDir, + cfg.Web.Directory, + ) } } func validateConfig(cfg *Config) error { - if cfg.SMTP.ListenPort < 1 || cfg.SMTP.ListenPort > 65535 { - return fmt.Errorf("invalid smtp.listen_port: %d", cfg.SMTP.ListenPort) + if cfg.SMTP.ListenPort < 1 || + cfg.SMTP.ListenPort > 65535 { + + return fmt.Errorf( + "smtp.listen_port must be between 1 and 65535", + ) } if cfg.SMTP.StorageDir == "" { - return fmt.Errorf("smtp.storage_dir must not be empty") + return fmt.Errorf( + "smtp.storage_dir must not be empty", + ) } if cfg.SMTP.DomainName == "" { - return fmt.Errorf("smtp.domain_name must not be empty") + return fmt.Errorf( + "smtp.domain_name must not be empty", + ) } if cfg.SMTP.MaxMessageSize <= 0 { - return fmt.Errorf("smtp.max_message_size must be greater than zero") + return fmt.Errorf( + "smtp.max_message_size must be greater than zero", + ) } - if cfg.Web.Enabled { - if cfg.Web.Port < 1 || cfg.Web.Port > 65535 { - return fmt.Errorf("invalid web.port: %d", cfg.Web.Port) - } + if !cfg.Web.Enabled { + return nil + } - if cfg.Web.Directory == "" { - return fmt.Errorf("web.directory must not be empty") - } + if cfg.Web.Port < 1 || + cfg.Web.Port > 65535 { - if cfg.Web.BasicAuth.Enabled { - if cfg.Web.BasicAuth.Username == "" { - return fmt.Errorf("web.basic_auth.username must not be empty") - } + return fmt.Errorf( + "web.port must be between 1 and 65535", + ) + } - if cfg.Web.BasicAuth.Password == "" { - return fmt.Errorf("web.basic_auth.password must not be empty") - } - } + if cfg.Web.Directory == "" { + return fmt.Errorf( + "web.directory must not be empty", + ) + } + + if !cfg.Web.Auth.Enabled { + return nil + } + + if cfg.Web.Auth.Username == "" { + return fmt.Errorf( + "web.auth.username must not be empty", + ) + } + + if cfg.Web.Auth.Password == "" { + return fmt.Errorf( + "web.auth.password must not be empty", + ) + } + + if cfg.Web.Auth.SessionTimeout == "" { + return fmt.Errorf( + "web.auth.session_timeout must not be empty", + ) + } + + sessionTimeout, err := time.ParseDuration( + cfg.Web.Auth.SessionTimeout, + ) + if err != nil { + return fmt.Errorf( + "invalid web.auth.session_timeout %q: %w", + cfg.Web.Auth.SessionTimeout, + err, + ) + } + + if sessionTimeout <= 0 { + return fmt.Errorf( + "web.auth.session_timeout must be greater than zero", + ) + } + + if cfg.Web.Auth.LoginMaxAttempts <= 0 { + return fmt.Errorf( + "web.auth.login_max_attempts must be greater than zero", + ) + } + + loginWindow, err := time.ParseDuration( + cfg.Web.Auth.LoginWindow, + ) + if err != nil { + return fmt.Errorf( + "invalid web.auth.login_window %q: %w", + cfg.Web.Auth.LoginWindow, + err, + ) + } + + if loginWindow <= 0 { + return fmt.Errorf( + "web.auth.login_window must be greater than zero", + ) + } + + loginLockout, err := time.ParseDuration( + cfg.Web.Auth.LoginLockout, + ) + if err != nil { + return fmt.Errorf( + "invalid web.auth.login_lockout %q: %w", + cfg.Web.Auth.LoginLockout, + err, + ) + } + + if loginLockout <= 0 { + return fmt.Errorf( + "web.auth.login_lockout must be greater than zero", + ) } return nil } -func LoadConfig(configPath string) (*Config, error) { +func LoadConfig( + configPath string, +) (*Config, error) { exeDir, err := getExecutableDir() if err != nil { log.Printf( "Warning: cannot get executable directory: %v, using current directory", err, ) + exeDir = "." } - finalConfigPath := configPath - if !filepath.IsAbs(finalConfigPath) { - finalConfigPath = filepath.Join(exeDir, finalConfigPath) + if configPath == "" { + configPath = "config.yaml" } - data, err := os.ReadFile(finalConfigPath) + finalConfigPath := configPath + + if !filepath.IsAbs(finalConfigPath) { + finalConfigPath = filepath.Join( + exeDir, + finalConfigPath, + ) + } + + finalConfigPath, err = filepath.Abs( + finalConfigPath, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to resolve config path: %w", + err, + ) + } + + log.Printf( + "Loading config from %s", + finalConfigPath, + ) + + data, err := os.ReadFile( + finalConfigPath, + ) + if err != nil { if !os.IsNotExist(err) { - return nil, fmt.Errorf("failed to read config: %w", err) + return nil, fmt.Errorf( + "failed to read config %s: %w", + finalConfigPath, + err, + ) } + // Конфига нет вообще: + // создаём новый default config. cfg := defaultConfig() - cfgData, marshalErr := yaml.Marshal(cfg) - if marshalErr != nil { + if err := validateConfig(cfg); err != nil { return nil, fmt.Errorf( - "failed to create default config data: %w", - marshalErr, + "default config is invalid: %w", + err, ) } - if writeErr := os.WriteFile(finalConfigPath, cfgData, 0600); writeErr != nil { + cfgData, err := yaml.Marshal(cfg) + if err != nil { return nil, fmt.Errorf( - "failed to create default config: %w", - writeErr, + "failed to encode default config: %w", + err, ) } - log.Printf("Created default config at %s", finalConfigPath) - log.Printf( - "WARNING: change the default web Basic Auth password in %s", + configDir := filepath.Dir( finalConfigPath, ) - resolveConfigPaths(cfg, exeDir) - - if err := validateConfig(cfg); err != nil { - return nil, err + if err := os.MkdirAll( + configDir, + 0755, + ); err != nil { + return nil, fmt.Errorf( + "failed to create config directory %s: %w", + configDir, + err, + ) } + if err := os.WriteFile( + finalConfigPath, + cfgData, + 0600, + ); err != nil { + return nil, fmt.Errorf( + "failed to create default config %s: %w", + finalConfigPath, + err, + ) + } + + log.Printf( + "Created default config at %s", + finalConfigPath, + ) + + resolveConfigPaths( + cfg, + exeDir, + ) + return cfg, nil } + /* + ВАЖНО: + + Если config.yaml существует, НЕ используем defaultConfig() + как основу. + + Иначе ошибочное/отсутствующее поле может незаметно получить + значение, зашитое в бинарник. + */ var cfg Config - if err := yaml.Unmarshal(data, &cfg); err != nil { + decoder := yaml.NewDecoder( + bytes.NewReader(data), + ) + + /* + Запрещаем неизвестные поля. + + Например старое: + + basic_auth: + + вместо: + + auth: + + теперь вызовет ошибку при запуске. + */ + decoder.KnownFields(true) + + if err := decoder.Decode(&cfg); err != nil { return nil, fmt.Errorf( "failed to parse YAML config %s: %w", finalConfigPath, @@ -189,7 +397,17 @@ func LoadConfig(configPath string) (*Config, error) { ) } - resolveConfigPaths(&cfg, exeDir) + log.Printf( + "Config loaded: web_auth=%v username=%q session_timeout=%s", + cfg.Web.Auth.Enabled, + cfg.Web.Auth.Username, + cfg.Web.Auth.SessionTimeout, + ) + + resolveConfigPaths( + &cfg, + exeDir, + ) return &cfg, nil } diff --git a/config.yaml b/config.yaml index 3d36277..2d6aeb3 100644 --- a/config.yaml +++ b/config.yaml @@ -11,7 +11,14 @@ web: port: 8089 directory: web - basic_auth: + auth: enabled: true + session_timeout: 15m + cookie_secure: false + username: admin password: admin + + login_max_attempts: 5 + login_window: 5m + login_lockout: 15m diff --git a/main.go b/main.go index 99a43d2..cc26633 100644 --- a/main.go +++ b/main.go @@ -14,7 +14,6 @@ type Program struct { webServer *WebServer } - func (p *Program) Start( s service.Service, ) error { diff --git a/web/app.js b/web/app.js index e3456dd..509727f 100644 --- a/web/app.js +++ b/web/app.js @@ -1,56 +1,88 @@ 'use strict'; -document.addEventListener('DOMContentLoaded', function () { - initializeEmailList(); -}); +var iframeObservers = new WeakMap(); + +document.addEventListener( + 'DOMContentLoaded', + function () { + initializeEmailList(); + } +); function initializeEmailList() { - var emailItems = document.querySelectorAll('.email-item'); + var emailItems = + document.querySelectorAll('.email-item'); emailItems.forEach(function (item) { - var summary = item.querySelector('.email-summary'); - var downloadButton = item.querySelector('.download-icon'); + var summary = + item.querySelector('.email-summary'); + + var downloadButton = + item.querySelector('.download-icon'); if (summary) { - summary.addEventListener('click', function (event) { - if (event.target.closest('.download-icon')) { - return; + summary.addEventListener( + 'click', + function (event) { + if ( + event.target.closest( + '.download-icon' + ) + ) { + return; + } + + toggleEmail(item); } + ); - toggleEmail(item); - }); + summary.addEventListener( + 'keydown', + function (event) { + if ( + event.key !== 'Enter' && + event.key !== ' ' + ) { + return; + } - summary.addEventListener('keydown', function (event) { - if ( - event.key !== 'Enter' && - event.key !== ' ' - ) { - return; + if ( + event.target.closest( + '.download-icon' + ) + ) { + return; + } + + event.preventDefault(); + + toggleEmail(item); } - - if (event.target.closest('.download-icon')) { - return; - } - - event.preventDefault(); - - toggleEmail(item); - }); + ); } if (downloadButton) { - downloadButton.addEventListener('click', function (event) { - event.stopPropagation(); + downloadButton.addEventListener( + 'click', + function (event) { + event.stopPropagation(); - var id = item.dataset.id; + var id = item.dataset.id; - if (!id) { - console.error('Email ID is missing'); - return; + if (!id) { + console.error( + 'Email ID is missing' + ); + + return; + } + + downloadEmail( + event, + id + ); } - - downloadEmail(event, id); - }); + ); } }); } @@ -59,14 +91,26 @@ function toggleEmail(item) { var id = item.dataset.id; if (!id) { - console.error('Email ID is missing'); + console.error( + 'Email ID is missing' + ); + return; } - var summary = item.querySelector('.email-summary'); - var detail = item.querySelector('.email-detail'); - var toggleIcon = item.querySelector('.toggle'); - var iframe = item.querySelector('.email-content-frame'); + var summary = + item.querySelector('.email-summary'); + + var detail = + item.querySelector('.email-detail'); + + var toggleIcon = + item.querySelector('.toggle'); + + var iframe = + item.querySelector( + '.email-content-frame' + ); if (!detail || !iframe) { console.error( @@ -77,13 +121,16 @@ function toggleEmail(item) { return; } - var isHidden = detail.classList.contains('hidden'); + var isHidden = + detail.classList.contains('hidden'); if (isHidden) { detail.classList.remove('hidden'); if (toggleIcon) { - toggleIcon.classList.add('rotated'); + toggleIcon.classList.add( + 'rotated' + ); } if (summary) { @@ -101,7 +148,9 @@ function toggleEmail(item) { detail.classList.add('hidden'); if (toggleIcon) { - toggleIcon.classList.remove('rotated'); + toggleIcon.classList.remove( + 'rotated' + ); } if (summary) { @@ -114,11 +163,19 @@ function toggleEmail(item) { } function loadEmailContent(id, iframe) { - if (iframe.dataset.loaded === 'true') { + if ( + iframe.dataset.loaded === 'true' + ) { resizeIframe(iframe); return; } + if ( + iframe.dataset.loading === 'true' + ) { + return; + } + iframe.dataset.loading = 'true'; fetch( @@ -130,6 +187,8 @@ function loadEmailContent(id, iframe) { } ) .then(function (response) { + handleUnauthorized(response); + if (!response.ok) { throw new Error( 'HTTP error ' + @@ -140,12 +199,15 @@ function loadEmailContent(id, iframe) { return response.text(); }) .then(function (data) { + resetIframeObserver(iframe); + writeEmailToIframe( iframe, data ); - iframe.dataset.loaded = 'true'; + iframe.dataset.loaded = + 'true'; delete iframe.dataset.loading; @@ -154,6 +216,17 @@ function loadEmailContent(id, iframe) { .catch(function (error) { delete iframe.dataset.loading; + // При 401 уже выполняется redirect. + if ( + error && + error.message === + 'Session expired' + ) { + return; + } + + resetIframeObserver(iframe); + writeErrorToIframe( iframe, error.message @@ -167,71 +240,94 @@ function loadEmailContent(id, iframe) { }); } -function writeEmailToIframe(iframe, emailBody) { - var doc = iframe.contentWindow.document; +function handleUnauthorized(response) { + if (response.status !== 401) { + return; + } + + window.location.href = '/login'; + + throw new Error( + 'Session expired' + ); +} + +function writeEmailToIframe( + iframe, + emailBody +) { + var doc = + iframe.contentWindow.document; doc.open(); doc.write( '' + + '' + + '' + + '' + - '' + + + '' + '' + '' + @@ -239,7 +335,7 @@ function writeEmailToIframe(iframe, emailBody) { '' + '' + - emailBody + + emailBody + '' + '' @@ -248,26 +344,36 @@ function writeEmailToIframe(iframe, emailBody) { doc.close(); } -function writeErrorToIframe(iframe, message) { - var doc = iframe.contentWindow.document; +function writeErrorToIframe( + iframe, + message +) { + var doc = + iframe.contentWindow.document; doc.open(); doc.write( '' + + '' + + '' + - '' + + '' + '' + + '' + + 'Error loading email: ' + escapeHTML(message) + + '' + + '' ); @@ -276,68 +382,81 @@ function writeErrorToIframe(iframe, message) { resizeIframe(iframe); } +function resetIframeObserver(iframe) { + var observer = + iframeObservers.get(iframe); + + if (observer) { + observer.disconnect(); + + iframeObservers.delete( + iframe + ); + } +} + function resizeIframe(iframe) { - if (!iframe || !iframe.contentWindow) { + if ( + !iframe || + !iframe.contentWindow + ) { return; } try { - var doc = iframe.contentWindow.document; + var doc = + iframe.contentWindow.document; if (!doc || !doc.body) { return; } - var height = Math.max( - doc.body.scrollHeight, - doc.documentElement - ? doc.documentElement.scrollHeight - : 0 + updateIframeHeight( + iframe, + doc ); - iframe.style.height = - Math.max(height + 20, 200) + - 'px'; - if ( - !iframe.dataset.resizeObserver && - typeof ResizeObserver !== 'undefined' + typeof ResizeObserver === + 'undefined' ) { - var observer = new ResizeObserver( + return; + } + + var existing = + iframeObservers.get(iframe); + + if (existing) { + return; + } + + var observer = + new ResizeObserver( function () { try { - var body = - iframe.contentWindow.document.body; + var currentDoc = + iframe + .contentWindow + .document; - var html = - iframe.contentWindow.document.documentElement; - - var newHeight = Math.max( - body - ? body.scrollHeight - : 0, - html - ? html.scrollHeight - : 0 + updateIframeHeight( + iframe, + currentDoc ); - - iframe.style.height = - Math.max( - newHeight + 20, - 200 - ) + - 'px'; } catch (error) { - + // Iframe may have been replaced. } } ); - observer.observe(doc.body); + observer.observe( + doc.body + ); - iframe.dataset.resizeObserver = - 'true'; - } + iframeObservers.set( + iframe, + observer + ); } catch (error) { console.warn( 'Cannot resize email iframe:', @@ -346,39 +465,173 @@ function resizeIframe(iframe) { } } -function downloadEmail(event, id) { - var icon = event.currentTarget; +function updateIframeHeight( + iframe, + doc +) { + if (!doc || !doc.body) { + return; + } + + var bodyHeight = + doc.body.scrollHeight; + + var htmlHeight = + doc.documentElement + ? doc.documentElement.scrollHeight + : 0; + + var height = Math.max( + bodyHeight, + htmlHeight, + 180 + ); + + iframe.style.height = + height + 20 + 'px'; +} + +function downloadEmail( + event, + id +) { + var button = + event.currentTarget; if (!id) { return; } var originalTransform = - icon.style.transform; + button.style.transform; var originalOpacity = - icon.style.opacity; + button.style.opacity; - icon.style.transform = + button.disabled = true; + + button.style.transform = 'scale(0.8)'; - icon.style.opacity = + button.style.opacity = '0.5'; - window.location.href = + fetch( '/api/download?id=' + - encodeURIComponent(id); + encodeURIComponent(id), + { + method: 'GET', + credentials: 'same-origin' + } + ) + .then(function (response) { + handleUnauthorized(response); - window.setTimeout( - function () { - icon.style.transform = + if (!response.ok) { + throw new Error( + 'HTTP error ' + + response.status + ); + } + + return Promise.all([ + response.blob(), + Promise.resolve( + getDownloadFilename( + response, + id + ) + ) + ]); + }) + .then(function (result) { + var blob = result[0]; + var filename = result[1]; + + var objectURL = + URL.createObjectURL(blob); + + var link = + document.createElement('a'); + + link.href = objectURL; + link.download = filename; + + document.body.appendChild( + link + ); + + link.click(); + link.remove(); + + window.setTimeout( + function () { + URL.revokeObjectURL( + objectURL + ); + }, + 1000 + ); + }) + .catch(function (error) { + if ( + error && + error.message === + 'Session expired' + ) { + return; + } + + console.error( + 'Failed to download email:', + id, + error + ); + + window.alert( + 'Failed to download email' + ); + }) + .finally(function () { + button.disabled = false; + + button.style.transform = originalTransform; - icon.style.opacity = + button.style.opacity = originalOpacity; - }, - 150 - ); + }); +} + +function getDownloadFilename( + response, + id +) { + var contentDisposition = + response.headers.get( + 'Content-Disposition' + ); + + if (contentDisposition) { + var match = + contentDisposition.match( + /filename="([^"]+)"/i + ); + + if (match && match[1]) { + return match[1]; + } + } + + var parts = id.split('/'); + + if (parts.length > 0) { + return parts[ + parts.length - 1 + ]; + } + + return 'email.eml'; } function escapeHTML(value) { diff --git a/web/index.html b/web/index.html index bb10d27..939ac65 100644 --- a/web/index.html +++ b/web/index.html @@ -19,28 +19,49 @@ +
- -
-

- Fake SMTP Server -

+
+ +
+

+ Fake SMTP Server +

+ +

+ Email storage +

+
+ +
+ + + +
-

- Email storage -

-
{{range .Days}}
-
+

{{.Date}}

@@ -48,9 +69,9 @@ {{len .Emails}} emails +
-
{{range .Emails}} @@ -60,13 +81,13 @@ data-id="{{.ID}}" > - +
{{else}} @@ -154,8 +179,10 @@ {{end}}
+
+ diff --git a/web/login.html b/web/login.html new file mode 100644 index 0000000..247cc27 --- /dev/null +++ b/web/login.html @@ -0,0 +1,101 @@ + + + + + + + + {{.Title}} + + + + + + + + +
+ +
+

+ Fake SMTP Server +

+ +

+ Sign in +

+
+ +
+ + {{if .Error}} + +
+ {{.Error}} +
+ + {{end}} + +
+ +
+ + + +
+ +
+ + + +
+ + + +
+ +
+
+ + + diff --git a/web_server.go b/web_server.go index b8fb37b..f03da79 100644 --- a/web_server.go +++ b/web_server.go @@ -2,6 +2,8 @@ package main import ( "bytes" + "context" + "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" @@ -11,17 +13,25 @@ import ( "html/template" "mime" "mime/quotedprintable" + "net" "net/http" "os" "path/filepath" - "regexp" "sort" + "strconv" "strings" + "sync" "time" "github.com/kardianos/service" ) +const sessionCookieName = "ric930_session" + +var errInvalidEmailID = errors.New( + "invalid email id", +) + type EmailFile struct { Name string `json:"name"` ID string `json:"id"` @@ -43,6 +53,34 @@ type cachedStaticFile struct { ContentType string } +type webSession struct { + LastActivity time.Time + CSRFToken string +} + +type sessionStore struct { + mu sync.Mutex + sessions map[string]*webSession + timeout time.Duration +} + +type loginAttempt struct { + Failures int + WindowStart time.Time + BlockedUntil time.Time + LastSeen time.Time +} + +type loginRateLimiter struct { + mu sync.Mutex + + attempts map[string]*loginAttempt + + maxAttempts int + window time.Duration + lockout time.Duration +} + type WebServer struct { config *Config smtpServer *SMTPServer @@ -52,49 +90,423 @@ type WebServer struct { template *template.Template templateErr error + loginTemplate *template.Template + loginTemplateErr error + staticFiles map[string]cachedStaticFile + + sessions *sessionStore + loginLimiter *loginRateLimiter } -func NewWebServer(cfg *Config, smtp *SMTPServer) *WebServer { - return &WebServer{ - config: cfg, - smtpServer: smtp, - staticFiles: make(map[string]cachedStaticFile), +func newSessionStore( + timeout time.Duration, +) *sessionStore { + return &sessionStore{ + sessions: make( + map[string]*webSession, + ), + timeout: timeout, } } -// decodeRFC2047 декодирует строку вида: -// -// =?UTF-8?B?...?= -func decodeRFC2047(encoded string) string { - decoder := new(mime.WordDecoder) +func generateSecureToken() ( + string, + error, +) { + data := make([]byte, 32) - decoded, err := decoder.Decode(encoded) + if _, err := rand.Read(data); err != nil { + return "", fmt.Errorf( + "failed to generate secure token: %w", + err, + ) + } + + return base64.RawURLEncoding. + EncodeToString(data), nil +} + +func (s *sessionStore) create() ( + string, + error, +) { + sessionID, err := generateSecureToken() if err != nil { - // Если стандартный декодер не справился, - // пробуем обработать простой UTF-8 Base64 вручную. - if strings.Contains(encoded, "=?UTF-8?B?") { - parts := strings.Split(encoded, "?B?") + return "", err + } - if len(parts) >= 2 { - b64part := strings.TrimSuffix(parts[1], "?=") + csrfToken, err := generateSecureToken() + if err != nil { + return "", err + } - if decodedBytes, err := base64.StdEncoding.DecodeString(b64part); err == nil { - return string(decodedBytes) - } - } + now := time.Now() + + s.mu.Lock() + defer s.mu.Unlock() + + for id, session := range s.sessions { + if now.Sub(session.LastActivity) > + s.timeout { + + delete( + s.sessions, + id, + ) + } + } + + s.sessions[sessionID] = &webSession{ + LastActivity: now, + CSRFToken: csrfToken, + } + + return sessionID, nil +} + +func (s *sessionStore) validate( + sessionID string, +) bool { + if sessionID == "" { + return false + } + + now := time.Now() + + s.mu.Lock() + defer s.mu.Unlock() + + session, ok := s.sessions[sessionID] + if !ok { + return false + } + + if now.Sub(session.LastActivity) > + s.timeout { + + delete( + s.sessions, + sessionID, + ) + + return false + } + + // Sliding inactivity timeout. + session.LastActivity = now + + return true +} + +func (s *sessionStore) csrfToken( + sessionID string, +) (string, bool) { + if sessionID == "" { + return "", false + } + + s.mu.Lock() + defer s.mu.Unlock() + + session, ok := s.sessions[sessionID] + if !ok { + return "", false + } + + return session.CSRFToken, true +} + +func (s *sessionStore) validateCSRF( + sessionID string, + token string, +) bool { + if sessionID == "" || + token == "" { + + return false + } + + s.mu.Lock() + defer s.mu.Unlock() + + session, ok := s.sessions[sessionID] + if !ok { + return false + } + + return constantTimeStringEqual( + session.CSRFToken, + token, + ) +} + +func (s *sessionStore) delete( + sessionID string, +) { + if sessionID == "" { + return + } + + s.mu.Lock() + defer s.mu.Unlock() + + delete( + s.sessions, + sessionID, + ) +} + +func newLoginRateLimiter( + maxAttempts int, + window time.Duration, + lockout time.Duration, +) *loginRateLimiter { + return &loginRateLimiter{ + attempts: make( + map[string]*loginAttempt, + ), + maxAttempts: maxAttempts, + window: window, + lockout: lockout, + } +} + +func (l *loginRateLimiter) allow( + key string, +) (bool, time.Duration) { + now := time.Now() + + l.mu.Lock() + defer l.mu.Unlock() + + for id, attempt := range l.attempts { + if attempt.BlockedUntil.After(now) { + continue } - return encoded + if now.Sub(attempt.LastSeen) > + l.window+l.lockout { + + delete( + l.attempts, + id, + ) + } } - return decoded + attempt, ok := l.attempts[key] + if !ok { + return true, 0 + } + + attempt.LastSeen = now + + if attempt.BlockedUntil.After(now) { + return false, + time.Until( + attempt.BlockedUntil, + ) + } + + if now.Sub(attempt.WindowStart) > + l.window { + + attempt.Failures = 0 + attempt.WindowStart = now + attempt.BlockedUntil = time.Time{} + } + + return true, 0 } -func decodeQuotedPrintable(data string) string { - reader := quotedprintable.NewReader( - strings.NewReader(data), +func (l *loginRateLimiter) failure( + key string, +) { + now := time.Now() + + l.mu.Lock() + defer l.mu.Unlock() + + attempt, ok := l.attempts[key] + if !ok { + attempt = &loginAttempt{ + WindowStart: now, + } + + l.attempts[key] = attempt + } + + if now.Sub(attempt.WindowStart) > + l.window { + + attempt.Failures = 0 + attempt.WindowStart = now + attempt.BlockedUntil = time.Time{} + } + + attempt.Failures++ + attempt.LastSeen = now + + if attempt.Failures >= + l.maxAttempts { + + attempt.BlockedUntil = + now.Add(l.lockout) + } +} + +func (l *loginRateLimiter) success( + key string, +) { + l.mu.Lock() + defer l.mu.Unlock() + + delete( + l.attempts, + key, ) +} + +func remoteIP( + remoteAddr string, +) string { + host, _, err := net.SplitHostPort( + remoteAddr, + ) + + if err == nil { + return host + } + + return remoteAddr +} + +func NewWebServer( + cfg *Config, + smtp *SMTPServer, +) *WebServer { + sessionTimeout := + 30 * time.Minute + + if cfg.Web.Auth.SessionTimeout != "" { + if parsed, err := + time.ParseDuration( + cfg.Web.Auth.SessionTimeout, + ); err == nil && + parsed > 0 { + + sessionTimeout = parsed + } + } + + loginWindow := + 5 * time.Minute + + if cfg.Web.Auth.LoginWindow != "" { + if parsed, err := + time.ParseDuration( + cfg.Web.Auth.LoginWindow, + ); err == nil && + parsed > 0 { + + loginWindow = parsed + } + } + + loginLockout := + 15 * time.Minute + + if cfg.Web.Auth.LoginLockout != "" { + if parsed, err := + time.ParseDuration( + cfg.Web.Auth.LoginLockout, + ); err == nil && + parsed > 0 { + + loginLockout = parsed + } + } + + maxAttempts := + cfg.Web.Auth.LoginMaxAttempts + + if maxAttempts <= 0 { + maxAttempts = 5 + } + + return &WebServer{ + config: cfg, + smtpServer: smtp, + + staticFiles: make( + map[string]cachedStaticFile, + ), + + sessions: newSessionStore( + sessionTimeout, + ), + + loginLimiter: newLoginRateLimiter( + maxAttempts, + loginWindow, + loginLockout, + ), + } +} + +func decodeRFC2047( + encoded string, +) string { + decoder := new( + mime.WordDecoder, + ) + + decoded, err := + decoder.Decode(encoded) + + if err == nil { + return decoded + } + + if strings.Contains( + encoded, + "=?UTF-8?B?", + ) { + parts := strings.Split( + encoded, + "?B?", + ) + + if len(parts) >= 2 { + b64part := strings.TrimSuffix( + parts[1], + "?=", + ) + + if decodedBytes, err := + base64.StdEncoding. + DecodeString( + b64part, + ); err == nil { + + return string( + decodedBytes, + ) + } + } + } + + return encoded +} + +func decodeQuotedPrintable( + data string, +) string { + reader := + quotedprintable.NewReader( + strings.NewReader(data), + ) buf := new(bytes.Buffer) @@ -103,7 +515,9 @@ func decodeQuotedPrintable(data string) string { return buf.String() } -func parseEmailHeaders(content []byte) ( +func parseEmailHeaders( + content []byte, +) ( from string, to string, subject string, @@ -120,14 +534,26 @@ func parseEmailHeaders(content []byte) ( ) for _, line := range lines { - line = strings.TrimSpace(line) + line = strings.TrimSpace( + line, + ) - lowerLine := strings.ToLower(line) + lowerLine := + strings.ToLower(line) switch { - case strings.HasPrefix(lowerLine, "from:"): - raw := strings.TrimSpace(line[5:]) - raw = strings.Trim(raw, "\"<> ") + case strings.HasPrefix( + lowerLine, + "from:", + ): + raw := strings.TrimSpace( + line[5:], + ) + + raw = strings.Trim( + raw, + "\"<> ", + ) from = decodeRFC2047(raw) @@ -135,9 +561,18 @@ func parseEmailHeaders(content []byte) ( from = "Unknown" } - case strings.HasPrefix(lowerLine, "to:"): - raw := strings.TrimSpace(line[3:]) - raw = strings.Trim(raw, "\"<> ") + case strings.HasPrefix( + lowerLine, + "to:", + ): + raw := strings.TrimSpace( + line[3:], + ) + + raw = strings.Trim( + raw, + "\"<> ", + ) to = decodeRFC2047(raw) @@ -145,33 +580,48 @@ func parseEmailHeaders(content []byte) ( to = "Unknown" } - case strings.HasPrefix(lowerLine, "subject:"): - raw := strings.TrimSpace(line[8:]) + case strings.HasPrefix( + lowerLine, + "subject:", + ): + raw := strings.TrimSpace( + line[8:], + ) - subject = decodeRFC2047(raw) + subject = + decodeRFC2047(raw) if subject == "" { subject = "No subject" } - case strings.HasPrefix(lowerLine, "date:"): - dateStr := strings.TrimSpace(line[5:]) + case strings.HasPrefix( + lowerLine, + "date:", + ): + dateStr := + strings.TrimSpace( + line[5:], + ) formats := []string{ time.RFC1123, time.RFC1123Z, time.RFC822, time.RFC822Z, + "Mon, 2 Jan 2006 15:04:05 -0700", "Mon, 2 Jan 2006 15:04:05 MST", "2 Jan 2006 15:04:05 -0700", } for _, format := range formats { - parsedDate, err := time.Parse( - format, - dateStr, - ) + + parsedDate, err := + time.Parse( + format, + dateStr, + ) if err == nil { date = parsedDate @@ -184,15 +634,48 @@ func parseEmailHeaders(content []byte) ( return } -func extractEmailBody(content []byte) string { - str := string(content) +func extractMIMEPartBody( + part string, +) string { + if idx := strings.Index( + part, + "\r\n\r\n", + ); idx != -1 { + return strings.TrimSuffix( + part[idx+4:], + "--", + ) + } + + if idx := strings.Index( + part, + "\n\n", + ); idx != -1 { + + return strings.TrimSuffix( + part[idx+2:], + "--", + ) + } + + return "" +} + +func extractEmailBody( + content []byte, +) string { + str := string(content) boundary := "" - lines := strings.Split(str, "\n") + lines := strings.Split( + str, + "\n", + ) for i, line := range lines { - lowerLine := strings.ToLower(line) + lowerLine := + strings.ToLower(line) if !strings.HasPrefix( lowerLine, @@ -205,10 +688,9 @@ func extractEmailBody(content []byte) string { line, "boundary=", ); idx != -1 { - boundaryPart := line[idx+9:] boundary = strings.Trim( - boundaryPart, + line[idx+9:], "\" \r\n", ) @@ -217,31 +699,35 @@ func extractEmailBody(content []byte) string { if i+1 < len(lines) && strings.Contains( - strings.ToLower(lines[i+1]), + strings.ToLower( + lines[i+1], + ), "boundary=", ) { - boundaryPart := lines[i+1] + nextLine := lines[i+1] if idx := strings.Index( - boundaryPart, + nextLine, "boundary=", ); idx != -1 { - boundary = strings.Trim( - boundaryPart[idx+9:], - "\" \r\n", - ) + + boundary = + strings.Trim( + nextLine[idx+9:], + "\" \r\n", + ) } } } - // Multipart. if boundary != "" { parts := strings.Split( str, "--"+boundary, ) + // Сначала text/plain. for _, part := range parts { if !strings.Contains( strings.ToLower(part), @@ -250,7 +736,11 @@ func extractEmailBody(content []byte) string { continue } - body := extractMIMEPartBody(part) + body := + extractMIMEPartBody( + part, + ) + if body == "" { continue } @@ -259,12 +749,18 @@ func extractEmailBody(content []byte) string { strings.ToLower(part), "content-transfer-encoding: quoted-printable", ) { - body = decodeQuotedPrintable(body) + body = + decodeQuotedPrintable( + body, + ) } - return strings.TrimSpace(body) + return strings.TrimSpace( + body, + ) } + // Затем text/html. for _, part := range parts { if !strings.Contains( strings.ToLower(part), @@ -273,7 +769,11 @@ func extractEmailBody(content []byte) string { continue } - body := extractMIMEPartBody(part) + body := + extractMIMEPartBody( + part, + ) + if body == "" { continue } @@ -282,53 +782,48 @@ func extractEmailBody(content []byte) string { strings.ToLower(part), "content-transfer-encoding: quoted-printable", ) { - body = decodeQuotedPrintable(body) + body = + decodeQuotedPrintable( + body, + ) } - return strings.TrimSpace(body) + return strings.TrimSpace( + body, + ) } } - // Обычное письмо без multipart. - body := extractMIMEPartBody(str) + body := + extractMIMEPartBody(str) + if body != "" { if strings.Contains( strings.ToLower(str), "content-transfer-encoding: quoted-printable", ) { - body = decodeQuotedPrintable(body) + body = + decodeQuotedPrintable( + body, + ) } - return strings.TrimSpace(body) + return strings.TrimSpace( + body, + ) } return "No content" } -func extractMIMEPartBody(part string) string { - if idx := strings.Index( - part, - "\r\n\r\n", - ); idx != -1 { - body := part[idx+4:] - return strings.TrimSuffix(body, "--") - } - - if idx := strings.Index( - part, - "\n\n", - ); idx != -1 { - body := part[idx+2:] - return strings.TrimSuffix(body, "--") - } - - return "" -} - -func listEmails(storageDir string) ([]DayEmails, error) { +func listEmails( + storageDir string, +) ([]DayEmails, error) { days := make([]DayEmails, 0) - entries, err := os.ReadDir(storageDir) + entries, err := + os.ReadDir(storageDir) + if err != nil { if os.IsNotExist(err) { return days, nil @@ -354,57 +849,84 @@ func listEmails(storageDir string) ([]DayEmails, error) { entry.Name(), ) - files, err := os.ReadDir(dayPath) + files, err := + os.ReadDir(dayPath) + if err != nil { continue } - emails := make([]EmailFile, 0) + emails := + make([]EmailFile, 0) for _, file := range files { if file.IsDir() { continue } + if file.Type()& + os.ModeSymlink != 0 { + + continue + } + if !strings.EqualFold( - filepath.Ext(file.Name()), + filepath.Ext( + file.Name(), + ), ".eml", ) { continue } - filePath := filepath.Join( - dayPath, - file.Name(), - ) - - info, err := file.Info() - if err != nil { - continue - } - - content, err := os.ReadFile(filePath) - if err != nil { - continue - } - - from, to, subject, date := - parseEmailHeaders(content) - - emailID := filepath.ToSlash( + filePath := filepath.Join( - entry.Name(), + dayPath, file.Name(), - ), - ) + ) + + info, err := + file.Info() + + if err != nil { + continue + } + + content, err := + os.ReadFile( + filePath, + ) + + if err != nil { + continue + } + + from, + to, + subject, + date := + parseEmailHeaders( + content, + ) + + emailID := + filepath.ToSlash( + filepath.Join( + entry.Name(), + file.Name(), + ), + ) emails = append( emails, EmailFile{ - Name: file.Name(), - ID: emailID, - Size: info.Size(), + Name: file.Name(), + ID: emailID, + + Size: info.Size(), + ModTime: info.ModTime(), + From: from, To: to, Subject: subject, @@ -416,9 +938,12 @@ func listEmails(storageDir string) ([]DayEmails, error) { sort.Slice( emails, func(i, j int) bool { - return emails[i].ModTime.After( - emails[j].ModTime, - ) + return emails[i]. + ModTime. + After( + emails[j]. + ModTime, + ) }, ) @@ -426,7 +951,8 @@ func listEmails(storageDir string) ([]DayEmails, error) { days = append( days, DayEmails{ - Date: entry.Name(), + Date: entry.Name(), + Emails: emails, }, ) @@ -436,7 +962,8 @@ func listEmails(storageDir string) ([]DayEmails, error) { sort.Slice( days, func(i, j int) bool { - return days[i].Date > days[j].Date + return days[i].Date > + days[j].Date }, ) @@ -450,19 +977,32 @@ func resolveEmailPath( id = strings.TrimSpace(id) if id == "" { - return "", fmt.Errorf("empty email id") + return "", fmt.Errorf( + "%w: empty id", + errInvalidEmailID, + ) } - if strings.Contains(id, "\\") { - return "", fmt.Errorf("invalid email id") + if strings.Contains( + id, + "\\", + ) { + return "", fmt.Errorf( + "%w: backslash is not allowed", + errInvalidEmailID, + ) } - parts := strings.Split(id, "/") + parts := strings.Split( + id, + "/", + ) - // Формат строго: - // YYYY-MM-DD/file.eml if len(parts) != 2 { - return "", fmt.Errorf("invalid email id") + return "", fmt.Errorf( + "%w: invalid path structure", + errInvalidEmailID, + ) } day := parts[0] @@ -473,16 +1013,20 @@ func resolveEmailPath( day, ); err != nil { return "", fmt.Errorf( - "invalid email date directory", + "%w: invalid date directory", + errInvalidEmailID, ) } if filename == "" || filename == "." || filename == ".." || - filepath.Base(filename) != filename { + filepath.Base(filename) != + filename { + return "", fmt.Errorf( - "invalid email filename", + "%w: invalid filename", + errInvalidEmailID, ) } @@ -491,11 +1035,26 @@ func resolveEmailPath( ".eml", ) { return "", fmt.Errorf( - "invalid email file type", + "%w: only .eml is allowed", + errInvalidEmailID, ) } - storageAbs, err := filepath.Abs(storageDir) + storageAbs, err := + filepath.Abs(storageDir) + + if err != nil { + return "", fmt.Errorf( + "cannot resolve storage directory: %w", + err, + ) + } + + storageResolved, err := + filepath.EvalSymlinks( + storageAbs, + ) + if err != nil { return "", fmt.Errorf( "cannot resolve storage directory: %w", @@ -509,7 +1068,9 @@ func resolveEmailPath( filename, ) - candidateAbs, err := filepath.Abs(candidate) + candidateAbs, err := + filepath.Abs(candidate) + if err != nil { return "", fmt.Errorf( "cannot resolve email path: %w", @@ -517,10 +1078,27 @@ func resolveEmailPath( ) } + candidateResolved, err := + filepath.EvalSymlinks( + candidateAbs, + ) + + if err != nil { + if os.IsNotExist(err) { + return "", os.ErrNotExist + } + + return "", fmt.Errorf( + "cannot resolve email file: %w", + err, + ) + } + rel, err := filepath.Rel( - storageAbs, - candidateAbs, + storageResolved, + candidateResolved, ) + if err != nil { return "", fmt.Errorf( "cannot validate email path: %w", @@ -531,90 +1109,99 @@ func resolveEmailPath( if rel == ".." || strings.HasPrefix( rel, - ".."+string(os.PathSeparator), + ".."+ + string( + os.PathSeparator, + ), ) || filepath.IsAbs(rel) { + return "", fmt.Errorf( - "email path escapes storage directory", + "%w: path escapes storage directory", + errInvalidEmailID, ) } - info, err := os.Stat(candidateAbs) + info, err := + os.Stat( + candidateResolved, + ) + if err != nil { if os.IsNotExist(err) { return "", os.ErrNotExist } return "", fmt.Errorf( - "cannot access email file: %w", + "cannot access email: %w", err, ) } if info.IsDir() { return "", fmt.Errorf( - "email id points to a directory", + "%w: email points to directory", + errInvalidEmailID, ) } - return candidateAbs, nil + return candidateResolved, nil } func (w *WebServer) loadWebAssets() { - w.staticFiles = make( - map[string]cachedStaticFile, - ) + w.staticFiles = + make( + map[string]cachedStaticFile, + ) - webDir := w.config.Web.Directory - - tmplFuncs := template.FuncMap{ - "safeID": func(s string) string { - reg := regexp.MustCompile( - `[^a-zA-Z0-9_-]`, - ) - - return reg.ReplaceAllString( - s, - "_", - ) - }, - } + webDir := + w.config.Web.Directory // index.html - templatePath := filepath.Join( - webDir, - "index.html", - ) + templatePath := + filepath.Join( + webDir, + "index.html", + ) + + templateData, err := + os.ReadFile( + templatePath, + ) - templateData, err := os.ReadFile(templatePath) if err != nil { w.template = nil - w.templateErr = fmt.Errorf( - "web template not found: %s: %w", - templatePath, - err, - ) + w.templateErr = + fmt.Errorf( + "web template not found: %s: %w", + templatePath, + err, + ) w.logger.Errorf( "%v", w.templateErr, ) } else { - tmpl, err := template.New( - "index.html", - ). - Funcs(tmplFuncs). - Parse(string(templateData)) + tmpl, err := + template.New( + "index.html", + ).Parse( + string( + templateData, + ), + ) if err != nil { w.template = nil - w.templateErr = fmt.Errorf( - "failed to parse web template %s: %w", - templatePath, - err, - ) + w.templateErr = + fmt.Errorf( + "failed to parse web template %s: %w", + templatePath, + err, + ) w.logger.Errorf( "%v", @@ -632,31 +1219,105 @@ func (w *WebServer) loadWebAssets() { } } + // login.html + loginTemplatePath := + filepath.Join( + webDir, + "login.html", + ) + + loginTemplateData, err := + os.ReadFile( + loginTemplatePath, + ) + + if err != nil { + w.loginTemplate = nil + + w.loginTemplateErr = + fmt.Errorf( + "login template not found: %s: %w", + loginTemplatePath, + err, + ) + + w.logger.Errorf( + "%v", + w.loginTemplateErr, + ) + } else { + tmpl, err := + template.New( + "login.html", + ).Parse( + string( + loginTemplateData, + ), + ) + + if err != nil { + w.loginTemplate = nil + + w.loginTemplateErr = + fmt.Errorf( + "failed to parse login template %s: %w", + loginTemplatePath, + err, + ) + + w.logger.Errorf( + "%v", + w.loginTemplateErr, + ) + } else { + w.loginTemplate = tmpl + + w.loginTemplateErr = nil + + w.logger.Infof( + "Login template loaded: %s (%d bytes)", + loginTemplatePath, + len( + loginTemplateData, + ), + ) + } + } + assets := []struct { Name string ContentType string }{ { - Name: "app.css", + Name: "app.css", + ContentType: "text/css; charset=utf-8", }, { - Name: "app.js", + Name: "app.js", + ContentType: "application/javascript; charset=utf-8", }, { - Name: "tailwind.js", + Name: "tailwind.js", + ContentType: "application/javascript; charset=utf-8", }, } for _, asset := range assets { - filePath := filepath.Join( - webDir, - asset.Name, - ) - data, err := os.ReadFile(filePath) + filePath := + filepath.Join( + webDir, + asset.Name, + ) + + data, err := + os.ReadFile( + filePath, + ) + if err != nil { w.logger.Warningf( "Cannot load web asset %s: %v", @@ -667,11 +1328,14 @@ func (w *WebServer) loadWebAssets() { continue } - urlPath := "/static/" + asset.Name + urlPath := + "/static/" + + asset.Name w.staticFiles[urlPath] = cachedStaticFile{ - Data: data, + Data: data, + ContentType: asset.ContentType, } @@ -689,6 +1353,7 @@ func (w *WebServer) handleStatic( ) { if r.Method != http.MethodGet && r.Method != http.MethodHead { + wr.Header().Set( "Allow", "GET, HEAD", @@ -704,8 +1369,13 @@ func (w *WebServer) handleStatic( } file, ok := w.staticFiles[r.URL.Path] + if !ok { - http.NotFound(wr, r) + http.NotFound( + wr, + r, + ) + return } @@ -731,16 +1401,447 @@ func constantTimeStringEqual( a string, b string, ) bool { - aHash := sha256.Sum256([]byte(a)) - bHash := sha256.Sum256([]byte(b)) + aHash := + sha256.Sum256( + []byte(a), + ) - return subtle.ConstantTimeCompare( - aHash[:], - bHash[:], - ) == 1 + bHash := + sha256.Sum256( + []byte(b), + ) + + return subtle. + ConstantTimeCompare( + aHash[:], + bHash[:], + ) == 1 } -func (w *WebServer) basicAuth( +func (w *WebServer) setSessionCookie( + wr http.ResponseWriter, + sessionID string, +) { + http.SetCookie( + wr, + &http.Cookie{ + Name: sessionCookieName, + + Value: sessionID, + + Path: "/", + + HttpOnly: true, + + Secure: w.config.Web.Auth. + CookieSecure, + + SameSite: http.SameSiteStrictMode, + }, + ) +} + +func (w *WebServer) clearSessionCookie( + wr http.ResponseWriter, +) { + http.SetCookie( + wr, + &http.Cookie{ + Name: sessionCookieName, + + Value: "", + + Path: "/", + + HttpOnly: true, + + Secure: w.config.Web.Auth. + CookieSecure, + + SameSite: http.SameSiteStrictMode, + + MaxAge: -1, + + Expires: time.Unix(1, 0), + }, + ) +} + +type loginPageData struct { + Title string + Error string +} + +func (w *WebServer) renderLogin( + wr http.ResponseWriter, + status int, + errorMessage string, +) { + if w.loginTemplate == nil { + message := + "Login template is unavailable" + + if w.loginTemplateErr != nil { + message = + w.loginTemplateErr. + Error() + } + + http.Error( + wr, + message, + http.StatusInternalServerError, + ) + + return + } + + wr.Header().Set( + "Content-Type", + "text/html; charset=utf-8", + ) + + wr.Header().Set( + "Cache-Control", + "no-store", + ) + + wr.WriteHeader(status) + + if err := + w.loginTemplate.Execute( + wr, + loginPageData{ + Title: "Fake SMTP Server - Login", + + Error: errorMessage, + }, + ); err != nil { + + w.logger.Errorf( + "Login template execution error: %v", + err, + ) + } +} + +func (w *WebServer) handleLogin( + wr http.ResponseWriter, + r *http.Request, +) { + if !w.config.Web.Auth.Enabled { + http.Redirect( + wr, + r, + "/", + http.StatusSeeOther, + ) + + return + } + + switch r.Method { + case http.MethodGet: + if cookie, err := + r.Cookie( + sessionCookieName, + ); err == nil { + + if w.sessions.validate( + cookie.Value, + ) { + http.Redirect( + wr, + r, + "/", + http.StatusSeeOther, + ) + + return + } + + w.clearSessionCookie( + wr, + ) + } + + w.renderLogin( + wr, + http.StatusOK, + "", + ) + + case http.MethodPost: + clientIP := + remoteIP( + r.RemoteAddr, + ) + + allowed, retryAfter := + w.loginLimiter.allow( + clientIP, + ) + + if !allowed { + seconds := int64( + (retryAfter + time.Second - 1) / time.Second, + ) + + if seconds < 1 { + seconds = 1 + } + + wr.Header().Set( + "Retry-After", + strconv.FormatInt( + seconds, + 10, + ), + ) + + w.logger.Warningf( + "Web login temporarily blocked for %s", + clientIP, + ) + + w.renderLogin( + wr, + http.StatusTooManyRequests, + "Too many failed login attempts. Try again later.", + ) + + return + } + + r.Body = + http.MaxBytesReader( + wr, + r.Body, + 16*1024, + ) + + if err := + r.ParseForm(); err != nil { + + w.renderLogin( + wr, + http.StatusBadRequest, + "Invalid request", + ) + + return + } + + username := + r.FormValue( + "username", + ) + + password := + r.FormValue( + "password", + ) + + validUsername := + constantTimeStringEqual( + username, + w.config.Web.Auth. + Username, + ) + + validPassword := + constantTimeStringEqual( + password, + w.config.Web.Auth. + Password, + ) + + if !validUsername || + !validPassword { + + w.loginLimiter.failure( + clientIP, + ) + + w.logger.Warningf( + "Failed web login from %s", + clientIP, + ) + + w.renderLogin( + wr, + http.StatusUnauthorized, + "Invalid username or password", + ) + + return + } + + w.loginLimiter.success( + clientIP, + ) + + // Защита от session fixation: + // старую сессию удаляем. + if oldCookie, err := + r.Cookie( + sessionCookieName, + ); err == nil { + + w.sessions.delete( + oldCookie.Value, + ) + } + + sessionID, err := + w.sessions.create() + + if err != nil { + w.logger.Errorf( + "Failed to create session: %v", + err, + ) + + http.Error( + wr, + "Failed to create session", + http.StatusInternalServerError, + ) + + return + } + + w.setSessionCookie( + wr, + sessionID, + ) + + w.logger.Infof( + "Web login successful from %s", + clientIP, + ) + + http.Redirect( + wr, + r, + "/", + http.StatusSeeOther, + ) + + default: + wr.Header().Set( + "Allow", + "GET, POST", + ) + + http.Error( + wr, + "Method not allowed", + http.StatusMethodNotAllowed, + ) + } +} + +func (w *WebServer) handleLogout( + wr http.ResponseWriter, + r *http.Request, +) { + if r.Method != + http.MethodPost { + + wr.Header().Set( + "Allow", + http.MethodPost, + ) + + http.Error( + wr, + "Method not allowed", + http.StatusMethodNotAllowed, + ) + + return + } + + r.Body = + http.MaxBytesReader( + wr, + r.Body, + 8*1024, + ) + + if err := + r.ParseForm(); err != nil { + + http.Error( + wr, + "Invalid request", + http.StatusBadRequest, + ) + + return + } + + cookie, err := + r.Cookie( + sessionCookieName, + ) + + if err != nil { + w.clearSessionCookie( + wr, + ) + + http.Redirect( + wr, + r, + "/login", + http.StatusSeeOther, + ) + + return + } + + csrfToken := + r.FormValue( + "csrf_token", + ) + + if !w.sessions.validateCSRF( + cookie.Value, + csrfToken, + ) { + w.logger.Warningf( + "Rejected logout with invalid CSRF token from %s", + r.RemoteAddr, + ) + + http.Error( + wr, + "Invalid CSRF token", + http.StatusForbidden, + ) + + return + } + + w.sessions.delete( + cookie.Value, + ) + + w.clearSessionCookie( + wr, + ) + + http.Redirect( + wr, + r, + "/login", + http.StatusSeeOther, + ) +} + +func (w *WebServer) sessionAuth( next http.Handler, ) http.Handler { return http.HandlerFunc( @@ -748,69 +1849,114 @@ func (w *WebServer) basicAuth( wr http.ResponseWriter, r *http.Request, ) { - authConfig := - w.config.Web.BasicAuth + if !w.config.Web.Auth.Enabled { + next.ServeHTTP( + wr, + r, + ) - if !authConfig.Enabled { - next.ServeHTTP(wr, r) return } - username, password, ok := - r.BasicAuth() + // Login должен быть публичным. + if r.URL.Path == + "/login" { - if !ok || - !constantTimeStringEqual( - username, - authConfig.Username, - ) || - !constantTimeStringEqual( - password, - authConfig.Password, - ) { - - wr.Header().Set( - "WWW-Authenticate", - `Basic realm="ric930-fake-smtp", charset="UTF-8"`, + next.ServeHTTP( + wr, + r, ) + return + } + + // Статика нужна странице login. + if strings.HasPrefix( + r.URL.Path, + "/static/", + ) { + next.ServeHTTP( + wr, + r, + ) + + return + } + + cookie, err := + r.Cookie( + sessionCookieName, + ) + + if err == nil && + w.sessions.validate( + cookie.Value, + ) { + + next.ServeHTTP( + wr, + r, + ) + + return + } + + w.clearSessionCookie( + wr, + ) + + if strings.HasPrefix( + r.URL.Path, + "/api/", + ) { http.Error( wr, - "Unauthorized", + "Session expired", http.StatusUnauthorized, ) - w.logger.Warningf( - "Unauthorized web access from %s to %s", - r.RemoteAddr, - r.URL.Path, - ) - return } - next.ServeHTTP(wr, r) + http.Redirect( + wr, + r, + "/login", + http.StatusSeeOther, + ) }, ) } func (w *WebServer) readEmailByID( id string, -) ([]byte, string, error) { - filePath, err := resolveEmailPath( - w.config.SMTP.StorageDir, - id, - ) +) ( + []byte, + string, + error, +) { + filePath, err := + resolveEmailPath( + w.config.SMTP.StorageDir, + id, + ) + if err != nil { return nil, "", err } - content, err := os.ReadFile(filePath) + content, err := + os.ReadFile( + filePath, + ) + if err != nil { return nil, "", err } - return content, filePath, nil + return content, + filePath, + nil } func (w *WebServer) handleEmailError( @@ -819,23 +1965,22 @@ func (w *WebServer) handleEmailError( id string, err error, ) { - if errors.Is(err, os.ErrNotExist) { - http.NotFound(wr, r) + if errors.Is( + err, + os.ErrNotExist, + ) { + http.NotFound( + wr, + r, + ) + return } - if strings.Contains( - err.Error(), - "invalid email", - ) || - strings.Contains( - err.Error(), - "escapes storage directory", - ) || - strings.Contains( - err.Error(), - "empty email id", - ) { + if errors.Is( + err, + errInvalidEmailID, + ) { w.logger.Warningf( "Rejected email id %q from %s: %v", id, @@ -870,12 +2015,17 @@ func (w *WebServer) handleIndex( r *http.Request, ) { if r.URL.Path != "/" { - http.NotFound(wr, r) + http.NotFound( + wr, + r, + ) + return } if r.Method != http.MethodGet && r.Method != http.MethodHead { + wr.Header().Set( "Allow", "GET, HEAD", @@ -891,10 +2041,12 @@ func (w *WebServer) handleIndex( } if w.template == nil { - message := "Web template is unavailable" + message := + "Web template is unavailable" if w.templateErr != nil { - message = w.templateErr.Error() + message = + w.templateErr.Error() } http.Error( @@ -906,9 +2058,11 @@ func (w *WebServer) handleIndex( return } - days, err := listEmails( - w.config.SMTP.StorageDir, - ) + days, err := + listEmails( + w.config.SMTP.StorageDir, + ) + if err != nil { w.logger.Errorf( "Error listing emails: %v", @@ -924,12 +2078,34 @@ func (w *WebServer) handleIndex( return } + csrfToken := "" + + if w.config.Web.Auth.Enabled { + if cookie, err := + r.Cookie( + sessionCookieName, + ); err == nil { + + if token, ok := + w.sessions.csrfToken( + cookie.Value, + ); ok { + + csrfToken = token + } + } + } + data := struct { - Days []DayEmails - Title string + Days []DayEmails + Title string + CSRFToken string }{ - Days: days, + Days: days, + Title: "Fake SMTP Server - Emails", + + CSRFToken: csrfToken, } wr.Header().Set( @@ -937,14 +2113,23 @@ func (w *WebServer) handleIndex( "text/html; charset=utf-8", ) - if r.Method == http.MethodHead { + wr.Header().Set( + "Cache-Control", + "no-store", + ) + + if r.Method == + http.MethodHead { + return } - if err := w.template.Execute( - wr, - data, - ); err != nil { + if err := + w.template.Execute( + wr, + data, + ); err != nil { + w.logger.Errorf( "Template execution error: %v", err, @@ -956,7 +2141,9 @@ func (w *WebServer) handleEmailsAPI( wr http.ResponseWriter, r *http.Request, ) { - if r.Method != http.MethodGet { + if r.Method != + http.MethodGet { + wr.Header().Set( "Allow", http.MethodGet, @@ -971,9 +2158,11 @@ func (w *WebServer) handleEmailsAPI( return } - days, err := listEmails( - w.config.SMTP.StorageDir, - ) + days, err := + listEmails( + w.config.SMTP.StorageDir, + ) + if err != nil { w.logger.Errorf( "Error listing emails: %v", @@ -989,7 +2178,9 @@ func (w *WebServer) handleEmailsAPI( http.StatusInternalServerError, ) - _ = json.NewEncoder(wr).Encode( + _ = json.NewEncoder( + wr, + ).Encode( map[string]string{ "error": "Error listing emails", }, @@ -1003,7 +2194,13 @@ func (w *WebServer) handleEmailsAPI( "application/json; charset=utf-8", ) - if err := json.NewEncoder(wr).Encode(days); err != nil { + if err := + json.NewEncoder( + wr, + ).Encode( + days, + ); err != nil { + w.logger.Errorf( "Failed to encode emails JSON: %v", err, @@ -1015,7 +2212,9 @@ func (w *WebServer) handleEmailAPI( wr http.ResponseWriter, r *http.Request, ) { - if r.Method != http.MethodGet { + if r.Method != + http.MethodGet { + wr.Header().Set( "Allow", http.MethodGet, @@ -1030,7 +2229,9 @@ func (w *WebServer) handleEmailAPI( return } - id := r.URL.Query().Get("id") + id := + r.URL.Query(). + Get("id") if id == "" { http.Error( @@ -1056,14 +2257,21 @@ func (w *WebServer) handleEmailAPI( return } - body := extractEmailBody(content) + body := + extractEmailBody( + content, + ) wr.Header().Set( "Content-Type", "text/html; charset=utf-8", ) - if _, err := wr.Write([]byte(body)); err != nil { + if _, err := + wr.Write( + []byte(body), + ); err != nil { + w.logger.Errorf( "Failed to send email %q: %v", id, @@ -1076,7 +2284,9 @@ func (w *WebServer) handleViewAPI( wr http.ResponseWriter, r *http.Request, ) { - if r.Method != http.MethodGet { + if r.Method != + http.MethodGet { + wr.Header().Set( "Allow", http.MethodGet, @@ -1091,7 +2301,9 @@ func (w *WebServer) handleViewAPI( return } - id := r.URL.Query().Get("id") + id := + r.URL.Query(). + Get("id") if id == "" { http.Error( @@ -1117,14 +2329,21 @@ func (w *WebServer) handleViewAPI( return } - body := extractEmailBody(content) + body := + extractEmailBody( + content, + ) wr.Header().Set( "Content-Type", "text/html; charset=utf-8", ) - if _, err := wr.Write([]byte(body)); err != nil { + if _, err := + wr.Write( + []byte(body), + ); err != nil { + w.logger.Errorf( "Failed to send email view %q: %v", id, @@ -1137,7 +2356,9 @@ func (w *WebServer) handleDownloadAPI( wr http.ResponseWriter, r *http.Request, ) { - if r.Method != http.MethodGet { + if r.Method != + http.MethodGet { + wr.Header().Set( "Allow", http.MethodGet, @@ -1152,7 +2373,9 @@ func (w *WebServer) handleDownloadAPI( return } - id := r.URL.Query().Get("id") + id := + r.URL.Query(). + Get("id") if id == "" { http.Error( @@ -1164,7 +2387,9 @@ func (w *WebServer) handleDownloadAPI( return } - content, filePath, err := + content, + filePath, + err := w.readEmailByID(id) if err != nil { @@ -1178,7 +2403,18 @@ func (w *WebServer) handleDownloadAPI( return } - filename := filepath.Base(filePath) + filename := + filepath.Base( + filePath, + ) + + disposition := + mime.FormatMediaType( + "attachment", + map[string]string{ + "filename": filename, + }, + ) wr.Header().Set( "Content-Type", @@ -1187,21 +2423,19 @@ func (w *WebServer) handleDownloadAPI( wr.Header().Set( "Content-Disposition", - fmt.Sprintf( - `attachment; filename="%s"`, - filename, - ), + disposition, ) wr.Header().Set( "Content-Length", - fmt.Sprintf( - "%d", + strconv.Itoa( len(content), ), ) - if _, err := wr.Write(content); err != nil { + if _, err := + wr.Write(content); err != nil { + w.logger.Errorf( "Failed to download email %q: %v", id, @@ -1219,6 +2453,7 @@ func (w *WebServer) Start() error { return nil } + // HTML/CSS/JS читаются один раз при запуске. w.loadWebAssets() mux := http.NewServeMux() @@ -1228,6 +2463,16 @@ func (w *WebServer) Start() error { w.handleStatic, ) + mux.HandleFunc( + "/login", + w.handleLogin, + ) + + mux.HandleFunc( + "/logout", + w.handleLogout, + ) + mux.HandleFunc( "/api/emails", w.handleEmailsAPI, @@ -1258,14 +2503,37 @@ func (w *WebServer) Start() error { w.config.Web.Port, ) - w.server = &http.Server{ - Addr: addr, + w.server = + &http.Server{ + Addr: addr, - Handler: w.basicAuth(mux), + Handler: w.sessionAuth(mux), - ReadTimeout: 10 * time.Second, - WriteTimeout: 10 * time.Second, - IdleTimeout: 60 * time.Second, + ReadTimeout: 10 * time.Second, + + WriteTimeout: 10 * time.Second, + + IdleTimeout: 60 * time.Second, + + ReadHeaderTimeout: 5 * time.Second, + } + + // net.Listen выполняется синхронно, + // поэтому занятый порт сразу вернёт ошибку из Start(). + listener, err := + net.Listen( + "tcp", + addr, + ) + + if err != nil { + w.server = nil + + return fmt.Errorf( + "failed to listen on %s: %w", + addr, + err, + ) } w.logger.Infof( @@ -1279,18 +2547,41 @@ func (w *WebServer) Start() error { ) w.logger.Infof( - "Web Basic Auth enabled: %v", - w.config.Web.BasicAuth.Enabled, + "Web Auth enabled: %v", + w.config.Web.Auth.Enabled, ) + if w.config.Web.Auth.Enabled { + w.logger.Infof( + "Web session inactivity timeout: %s", + w.config.Web.Auth.SessionTimeout, + ) + + w.logger.Infof( + "Web session cookie Secure: %v", + w.config.Web.Auth.CookieSecure, + ) + + w.logger.Infof( + "Web login rate limit: %d attempts / %s, lockout %s", + w.config.Web.Auth.LoginMaxAttempts, + w.config.Web.Auth.LoginWindow, + w.config.Web.Auth.LoginLockout, + ) + } + go func() { - err := w.server.ListenAndServe() + err := + w.server.Serve( + listener, + ) if err != nil && !errors.Is( err, http.ErrServerClosed, ) { + w.logger.Errorf( "Web server error: %v", err, @@ -1310,5 +2601,25 @@ func (w *WebServer) Stop() error { "Stopping web server...", ) - return w.server.Close() + ctx, cancel := + context.WithTimeout( + context.Background(), + 5*time.Second, + ) + + defer cancel() + + err := + w.server.Shutdown(ctx) + + w.server = nil + + if err != nil { + return fmt.Errorf( + "failed to stop web server: %w", + err, + ) + } + + return nil }