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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ plikd_*.cfg
.vscode
plik.iml
coverage.out
/plans
47 changes: 44 additions & 3 deletions docs/guide/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,23 @@ Plik allows users to upload and serve any content as-is. Hosting untrusted conte

For security reasons, Plik doesn't trust user-provided MIME types and relies solely on server-side detection. This means some files may not render properly in browsers or embedded viewers that require the correct MIME type.

### Dangerous Content-Type Neutralization

Plik automatically neutralizes content types that could execute code in the browser:

| Original type | Served as | Reason |
|---|---|---|
| `text/html`, `*html*` | `application/octet-stream` | Prevents inline script execution |
| `image/svg+xml`, `*svg*` | `application/octet-stream` | SVG can contain `onload` JavaScript handlers |
| `text/xml`, `*xml*` | `application/octet-stream` | XML can be parsed and rendered by browsers |
| `application/javascript`, `*javascript*` | `application/octet-stream` | Prevents script execution |
| `application/x-shockwave-flash` | `application/octet-stream` | Flash content |
| `application/pdf` | `application/octet-stream` | PDF can contain JavaScript |

::: tip
This protection is always active regardless of `EnhancedWebSecurity`. Use the `?dl=true` query parameter to force a download with `Content-Disposition: attachment`.
:::

::: warning Office format detection limitation
Office formats like `.pptx`, `.docx`, and `.xlsx` are ZIP archives internally, so Go's built-in MIME detector (`http.DetectContentType`) identifies them as `application/zip` instead of their proper types (e.g., `application/vnd.openxmlformats-officedocument.presentationml.presentation`).
:::
Expand All @@ -18,14 +35,38 @@ When `EnhancedWebSecurity` is enabled in `plikd.cfg`, Plik sets additional HTTP
- **X-XSS-Protection**: enabled
- **X-Frame-Options**: deny
- **Content-Security-Policy**: restrictive policy disabling resource loading, XHR, iframes
- **Secure Cookies**: session cookies only transmitted over HTTPS
- **Strict-Transport-Security**: `max-age=31536000` (1 year) — also set when `SslEnabled` is true
- **Secure Cookies**: session cookies only transmitted over HTTPS — also set when `SslEnabled` is true

::: warning
Enhanced security will break audio/video playback, PDF rendering, and other rich content features. Disable it if you need those capabilities.
:::

::: danger Authentication requires HTTPS with EnhancedWebSecurity
When `EnhancedWebSecurity` is enabled, session cookies have the `Secure` flag set and can only be transmitted over HTTPS connections. Authentication will not work over plain HTTP.
::: danger Authentication requires HTTPS with Secure Cookies
When `EnhancedWebSecurity` or `SslEnabled` is enabled, session cookies have the `Secure` flag set and can only be transmitted over HTTPS connections. Authentication will not work over plain HTTP.
:::

## Upload Password Protection

When `FeaturePassword` is enabled, uploads can be protected with a login/password pair. Credentials are transmitted via HTTP Basic Authentication.

Passwords are hashed using **bcrypt(sha256(credentials))** before storage; the plaintext is never persisted. The SHA-256 pre-hash ensures credentials of any length are securely handled within bcrypt's input constraints.

| Parameter | Limit |
|-----------|-------|
| Login | 128 characters max |
| Password | 128 characters max |

::: tip
Legacy uploads (created before version 1.4) use MD5 hashing and continue to work until they expire.
:::

## Removable Uploads

When `FeatureRemovable` is enabled and an upload is created with `removable: true`, **anyone with the upload URL can delete the upload and its files** — no upload token or authentication is required. This is by design: the `removable` flag is intended for ephemeral, public uploads where ease of cleanup is prioritized over access control.

::: warning
If you need to control who can delete an upload, do **not** set `removable: true`. Only the upload owner (via upload token, API token, or session) can delete non-removable uploads.
:::

## Download Domain
Expand Down
20 changes: 20 additions & 0 deletions server/common/no_dir_listing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package common

import (
"net/http"
"strings"
)

// NoDirListing wraps an http.Handler (typically http.FileServer) to return
// 404 for directory requests instead of generating a directory listing.
// The root path "/" is allowed through so that index.html is served for SPAs.
// All other paths ending in "/" are blocked.
func NoDirListing(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" && strings.HasSuffix(r.URL.Path, "/") {
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r)
})
}
60 changes: 60 additions & 0 deletions server/common/no_dir_listing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package common

import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/require"
)

func TestNoDirListing_BlocksSubdirectory(t *testing.T) {
dir := t.TempDir()
subdir := filepath.Join(dir, "subdir")
require.NoError(t, os.Mkdir(subdir, 0755))
require.NoError(t, os.WriteFile(filepath.Join(subdir, "file.txt"), []byte("hello"), 0644))

fs := NoDirListing(http.FileServer(http.Dir(dir)))

// Subdirectory listing should return 404
req, err := http.NewRequest("GET", "/subdir/", nil)
require.NoError(t, err)

rr := httptest.NewRecorder()
fs.ServeHTTP(rr, req)
require.Equal(t, http.StatusNotFound, rr.Code)
}

func TestNoDirListing_AllowsRootIndex(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html>ok</html>"), 0644))

fs := NoDirListing(http.FileServer(http.Dir(dir)))

// Root "/" should pass through (serves index.html)
req, err := http.NewRequest("GET", "/", nil)
require.NoError(t, err)

rr := httptest.NewRecorder()
fs.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
require.Contains(t, rr.Body.String(), "<html>ok</html>")
}

func TestNoDirListing_AllowsFile(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "test.txt"), []byte("hello"), 0644))

fs := NoDirListing(http.FileServer(http.Dir(dir)))

// File request should succeed
req, err := http.NewRequest("GET", "/test.txt", nil)
require.NoError(t, err)

rr := httptest.NewRecorder()
fs.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
require.Equal(t, "hello", rr.Body.String())
}
28 changes: 28 additions & 0 deletions server/common/password.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package common

import (
"crypto/sha256"
"fmt"

"golang.org/x/crypto/bcrypt"
)

// HashUploadPassword hashes upload credentials using bcrypt(sha256(base64(login:password))).
// The SHA-256 pre-hash produces a fixed 32-byte (64-hex) digest, removing bcrypt's
// 72-byte input limit and allowing arbitrarily long credentials.
func HashUploadPassword(login string, password string) (string, error) {
b64 := EncodeAuthBasicHeader(login, password)
digest := sha256.Sum256([]byte(b64))
hash, err := bcrypt.GenerateFromPassword(fmt.Appendf(nil, "%x", digest), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}

// CheckUploadPassword verifies upload credentials against a stored bcrypt(sha256) hash.
func CheckUploadPassword(b64Creds string, storedHash string) bool {
digest := sha256.Sum256([]byte(b64Creds))
err := bcrypt.CompareHashAndPassword([]byte(storedHash), fmt.Appendf(nil, "%x", digest))
return err == nil
}
41 changes: 41 additions & 0 deletions server/common/password_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package common

import (
"strings"
"testing"

"github.com/stretchr/testify/require"
)

func TestHashUploadPassword(t *testing.T) {
hash, err := HashUploadPassword("login", "password")
require.NoError(t, err)
require.True(t, len(hash) > 0)
require.Equal(t, "$2", hash[:2], "hash should be bcrypt format")
}

func TestCheckUploadPassword(t *testing.T) {
hash, err := HashUploadPassword("login", "password")
require.NoError(t, err)

b64 := EncodeAuthBasicHeader("login", "password")

// Correct credentials
require.True(t, CheckUploadPassword(b64, hash))

// Wrong credentials
wrongB64 := EncodeAuthBasicHeader("login", "wrong")
require.False(t, CheckUploadPassword(wrongB64, hash))
}

func TestHashUploadPasswordLongCredentials(t *testing.T) {
// 128-char login and password should work (bcrypt's 72-byte limit is removed by SHA-256 pre-hash)
longLogin := strings.Repeat("a", 128)
longPass := strings.Repeat("b", 128)

hash, err := HashUploadPassword(longLogin, longPass)
require.NoError(t, err)

b64 := EncodeAuthBasicHeader(longLogin, longPass)
require.True(t, CheckUploadPassword(b64, hash))
}
20 changes: 18 additions & 2 deletions server/common/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,31 @@ func StripPrefix(prefix string, handler http.Handler) http.Handler {
}

// SanitizeFilenameForDisposition strips characters that could break a
// Content-Disposition header value: double quotes, CR, LF, and null bytes.
// Content-Disposition header value: double quotes, CR, LF, null bytes,
// and Unicode BiDi override characters that can spoof file extensions.
// It also truncates excessively long names to 1024 characters.
func SanitizeFilenameForDisposition(name string) string {
r := strings.NewReplacer(
`"`, "",
"\r", "",
"\n", "",
"\x00", "",
// Unicode BiDi overrides — can make "evil\u202Efdp.exe" appear as "evil exe.pdf"
"\u202A", "", // LRE (Left-to-Right Embedding)
"\u202B", "", // RLE (Right-to-Left Embedding)
"\u202C", "", // PDF (Pop Directional Formatting)
"\u202D", "", // LRO (Left-to-Right Override)
"\u202E", "", // RLO (Right-to-Left Override)
"\u2066", "", // LRI (Left-to-Right Isolate)
"\u2067", "", // RLI (Right-to-Left Isolate)
"\u2068", "", // FSI (First Strong Isolate)
"\u2069", "", // PDI (Pop Directional Isolate)
)
return r.Replace(name)
name = r.Replace(name)
if len(name) > 1024 {
name = name[:1024]
}
return name
}

// EncodeAuthBasicHeader return the base64 version of "login:password"
Expand Down
15 changes: 15 additions & 0 deletions server/common/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -138,4 +139,18 @@ func TestSanitizeFilenameForDisposition(t *testing.T) {

// Multiple dangerous characters at once
require.Equal(t, "malicious.txt", SanitizeFilenameForDisposition("mal\"\rici\nous\x00.txt"))

// Filename truncated at 1024 characters
longName := strings.Repeat("a", 1025)
require.Len(t, SanitizeFilenameForDisposition(longName), 1024)

// Exactly 1024 passes through
exactName := strings.Repeat("b", 1024)
require.Len(t, SanitizeFilenameForDisposition(exactName), 1024)

// Unicode BiDi override characters are stripped (RLO can spoof file extensions)
require.Equal(t, "evilfdp.exe", SanitizeFilenameForDisposition("evil\u202Efdp.exe"))

// Multiple BiDi overrides are stripped
require.Equal(t, "safe.txt", SanitizeFilenameForDisposition("\u202A\u202Bsafe\u2066.\u2069txt\u202C"))
}
8 changes: 8 additions & 0 deletions server/context/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,14 @@ func (ctx *Context) SetAuthenticator(authenticator *common.SessionAuthenticator)
ctx.authenticator = authenticator
}

// GetAuthenticatorSafe get authenticator from the context (nil-safe, no panic).
func (ctx *Context) GetAuthenticatorSafe() *common.SessionAuthenticator {
ctx.mu.RLock()
defer ctx.mu.RUnlock()

return ctx.authenticator
}

// GetMetrics get metrics from the context.
func (ctx *Context) GetMetrics() *common.PlikMetrics {
ctx.mu.RLock()
Expand Down
21 changes: 18 additions & 3 deletions server/context/upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import (
"time"

"github.com/dustin/go-humanize"

"github.com/root-gg/plik/server/common"
"github.com/root-gg/utils"
)

// CreateUpload from params and context (check configuration and default values, generate upload and file IDs, ... )
Expand Down Expand Up @@ -38,6 +38,9 @@ func (ctx *Context) CreateUpload(params *common.Upload) (upload *common.Upload,
}

// Handle Basic Auth parameters
if params.ProtectedByPassword && params.Password == "" {
return nil, fmt.Errorf("upload password is empty")
}
err = ctx.setBasicAuth(upload, params.Login, params.Password)
if err != nil {
return nil, err
Expand Down Expand Up @@ -178,6 +181,10 @@ func (ctx *Context) setParams(upload *common.Upload, params *common.Upload) (err
upload.Comments = params.Comments
}

if len(upload.Comments) > 32768 {
return fmt.Errorf("comment is too long (max 32768 bytes)")
}

upload.E2EE = params.E2EE
if upload.E2EE != "" && !common.IsValidE2EEScheme(upload.E2EE) {
return fmt.Errorf("invalid e2ee scheme %q", upload.E2EE)
Expand Down Expand Up @@ -250,6 +257,13 @@ func (ctx *Context) setBasicAuth(upload *common.Upload, login string, password s
return nil
}

if len(login) > 128 {
return fmt.Errorf("login too long (max 128 characters)")
}
if len(password) > 128 {
return fmt.Errorf("password too long (max 128 characters)")
}

if login != "" {
upload.Login = login
} else {
Expand All @@ -258,8 +272,9 @@ func (ctx *Context) setBasicAuth(upload *common.Upload, login string, password s

upload.ProtectedByPassword = true

// Save only the md5sum of this string to authenticate further requests
upload.Password, err = utils.Md5sum(common.EncodeAuthBasicHeader(upload.Login, password))
// Hash credentials with bcrypt(sha256(base64(login:password)))
// SHA-256 pre-hash removes bcrypt's 72-byte input limit
upload.Password, err = common.HashUploadPassword(upload.Login, password)
if err != nil {
return fmt.Errorf("unable to generate password hash : %s", err)
}
Expand Down
Loading
Loading