fileblock

package
v1.0.220 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 10, 2026 License: MIT Imports: 15 Imported by: 0

Documentation

Overview

Package fileblock is the file-type blocking engine: an extension/MIME/ Content-Disposition blocklist (FileBlocker) and named file-type profiles (FileProfileStore, in fileprofile.go). It depends only on the shared seam (internal/obs for logging, internal/fileutil for atomic persistence) and the standard library — no dependency on the rest of Culvert (ADR-0002 leaf, unblocked by the ADR-0003 seam).

Index

Constants

This section is empty.

Variables

View Source
var DefaultBlockedExts = []string{
	".exe", ".dll", ".bat", ".cmd", ".ps1",
	".vbs", ".scr", ".msi", ".pif", ".com",
}

DefaultBlockedExts is loaded at startup when no config override is provided. Covers common Windows malware/script delivery formats.

Functions

func BlockConn

func BlockConn(dst interface {
	Write([]byte) (int, error)
	Close() error
}, host, urlPath, ext, source string)

BlockConn writes a synthetic HTTP/1.1 403 response to a raw connection and closes it. Retained for the raw-conn callers and its own test; the SSL-inspect path no longer uses it (it emits via the protocol-neutral blockResponder so the same detector serves HTTP/2, where closing the shared conn on a per-stream block would kill sibling streams and Connection: close is illegal per RFC 9113 §8.2.2). The force-close here prevents HTTP/1.1 pipelined-request bypass.

func BlockMessage added in v1.0.70

func BlockMessage(ext, source string) string

BlockMessage returns the plain-text body of a file-block 403 for the given extension and source label. Exposed so the SSL-inspect path can emit the block through its protocol-neutral responder (HTTP/1.1 or HTTP/2) without this package owning HTTP framing; the legacy raw-conn BlockConn uses it too.

func ExtractCDFilename

func ExtractCDFilename(cd string) string

ExtractCDFilename extracts the filename from a Content-Disposition header. Returns "" if no filename is found. Used by per-rule file profile checking when the download URL doesn't contain the file extension (e.g. SourceForge's /files/latest/download pattern).

func LogBlock added in v1.0.70

func LogBlock(host, urlPath, ext, source string)

LogBlock emits the FILE_BLOCKED tunnel observability line. Split out of BlockConn so the SSL-inspect path can log identically while emitting the wire block through its own responder (keeping this package framing-free).

func ValidateProfiles added in v1.0.218

func ValidateProfiles(profiles []FileExtProfile) error

ValidateProfiles is the canonical FileProfile SET validation seam (2D-C final §13–§14), applied at every trust/load boundary (disk Load, CP→DP ConfigSnapshot preflight, ReplaceAll): IDs are enforcement-authoritative, so a set with a missing or duplicate ID is ambiguous identity and must be refused — never repaired by minting fresh identity nondeterministically per load. Names are the interactive intent namespace and must be non-empty and case-insensitively unique. IDs are deliberately NOT required to be UUIDs: the seeded built-ins carry deterministic `builtin-*` IDs by design. Missing IDs are corruption, not legacy: FileExtProfile was born with the ID field, the built-in IDs, and a uuid-minting Create (§14 audit) — every version that ever persisted profiles wrote IDs.

Types

type ErrRevisionConflict added in v1.0.218

type ErrRevisionConflict struct{ Current string }

ErrRevisionConflict is returned by a fenced mutation whose asserted revision no longer matches the committed profile set (another admin mutated it in between). Carries the CURRENT revision so the API can render a structured 409 the client uses to refresh.

func (*ErrRevisionConflict) Error added in v1.0.218

func (e *ErrRevisionConflict) Error() string

type FileBlocker

type FileBlocker struct {
	// contains filtered or unexported fields
}

FileBlocker holds the set of file extensions to block. Extensions are normalised to lowercase with a leading dot (e.g. ".exe"). All operations are safe for concurrent use. Persists to a JSON file when a path is configured via SetPath().

── The per-transaction gate is LOCK-FREE ─────────────────────────────────────

CheckPath runs on every plain-HTTP request (proxy.go preDispatchBlocked) and every SSL-inspected inner request; CheckContentType and CheckContentDisposition run on every plain-HTTP response (proxy_http.go blockedByResponseHeaders) and every inspected response (proxy_tunnel.go inspectFileBlocked). So one inspected transaction reached this store up to THREE times, and each probe took mu.RLock — an atomic read-modify-write on ONE process-wide word — purely to read a set that in steady state never changes (the shipped default is ten extensions seeded once at startup).

That is not a constant cost but a THROUGHPUT CEILING, the same shape already recorded for internal/threatfeed, security.go's IP filter, internal/connlimit and metrics.go's latency histogram. Measured on this machine (4-vCPU Xeon, Go 1.26, CheckPath against "/static/app.js", medians of n=15; BenchmarkCheckPathParallel{,Legacy}, whose ns/op is already wall-normalized across workers, so 1-core-ns ÷ 4-core-ns IS the scaling factor):

       │ 1 core  │ 4 workers │ scaling
before │ 28.4 ns │  105.6 ns │ 0.27x — four cores delivered a QUARTER of one
after  │ 19.7 ns │   17.2 ns │ 1.15x — flat, and 6.2x the old four-core ceiling

Note the shape, not the constants: the "after" row is FLAT, not linear. The published view is a shared read-only cache line, so it does not scale a four-worker load on a 4-vCPU VM into 4x; what it removes is the DEGRADATION. The absolute numbers are load-sensitive by construction — a contended lock degrades further the busier the box is, so the "before" row understates the problem on the larger hardware the appliance ships to.

Reads now go through an immutable view published via atomic.Pointer. THE CONTRACT IS ONE LINE AND IT IS LOAD-BEARING: a map reachable from a published view is never mutated in place. The mu-guarded fields stay the AUTHORITATIVE write-side state — every mutator keeps its exact prior semantics and calls publishLocked() before releasing the lock. Adding a mutator without that call is a silent SECURITY failure, not a performance one (a newly-blocked extension that never blocks, a removed one that keeps blocking), so it is pinned per mutator by TestBlockerView_EveryMutatorRepublishes.

publishLocked COPIES rather than aliasing, so an Add loop is O(N) per call. That introduces no new complexity class: Add already calls save(), which marshals the WHOLE set and rewrites the file on every single call, so the bulk paths were quadratic in bytes-to-disk before this change and the map copy is strictly cheaper than the marshal it sits beside. ReplaceAll remains the batch entry point for bulk loads (CL-13).

func NewBlocker

func NewBlocker() *FileBlocker

NewBlocker returns an empty, ready-to-use FileBlocker. package main holds the process-wide singleton (var fileBlocker = fileblock.NewBlocker()).

func (*FileBlocker) Add

func (fb *FileBlocker) Add(ext string)

Add inserts a normalised extension into the block list and persists.

func (*FileBlocker) CheckContentDisposition

func (fb *FileBlocker) CheckContentDisposition(cd string) string

CheckContentDisposition returns the blocked extension if the Content-Disposition response header carries a filename with a blocked extension (catches downloads that use a generic URL but declare the real file name in the header).

func (*FileBlocker) CheckContentType

func (fb *FileBlocker) CheckContentType(contentType string) string

CheckContentType returns the blocked extension if the response Content-Type header matches a dangerous MIME type whose associated extension is in the block list. This prevents bypass by renaming files (e.g. malware.exe → malware.txt). The contentType parameter should be the raw Content-Type header value (e.g. "application/x-msdownload; charset=utf-8").

── Why the media type is decided before the header is parsed ─────────────────

This runs on EVERY proxied response that carries a Content-Type — the plain-HTTP forward path (proxy_http.go blockedByResponseHeaders) and the SSL-inspect path (proxy_tunnel.go inspectFileBlocked) alike. It consumed the full mime.ParseMediaType, which allocates a map[string]string for the header PARAMETERS and then walks the header to fill it — and this function discards that map. Every response paid it so that a ten-entry lookup could miss.

Measured on this machine (4-vCPU Xeon, Go 1.26, "text/html; charset=utf-8" — the shape almost all web traffic carries; medians of n=6):

before   281.7 ns/op   336 B/op   2 allocs/op
after     35.6 ns/op     0 B/op   0 allocs/op

The trade-off is stated rather than papered over: the ten dangerous media types now pay the cheap split AND the parse, 347.2 -> 384.8 ns (+11%). They are the arm that is about to serve a block page and write two log lines, and ordinary traffic never reaches it, so the exchange is accepted deliberately. BenchmarkCheckContentType_Dangerous keeps it measurable.

The parse is now reached ONLY for a header whose media type is one of the ten dangerous types, i.e. never on ordinary traffic. The pre-filter is a pure NEGATIVE one: it can only return the empty (allow) answer early, and every BLOCK is still decided by the original body, unchanged, parse included. So the malformed-parameter case still declines to block exactly as before — ParseMediaType reports ErrInvalidMediaParameter for "application/x-msdownload; bogus" and this returns "", which a naive prefix split would have turned into a block. That would have been a tightening, but a tightening is still a behaviour change in a cost fix, so it is deliberately not taken here.

The equivalence is exact, not approximate. ParseMediaType computes its returned mediatype as strings.TrimSpace(strings.ToLower(base)) over base, _, _ := strings.Cut(v, ";") — reproduced verbatim below — and returns a non-nil error otherwise, in which case the pre-fix body returned "" as well. So "candidate is not a blocked MIME type" implies the pre-fix body returned "" for every input, including the malformed ones. Pinned against a verbatim copy of the pre-fix body by TestCheckContentType_MatchesPreFilterBehaviour and FuzzCheckContentType, so an upstream mime behaviour change fails CI rather than silently diverging.

func (*FileBlocker) CheckExt

func (fb *FileBlocker) CheckExt(ext string) string

CheckExt checks if a specific extension is in the block list. Extension should include the dot (e.g., ".exe").

func (*FileBlocker) CheckPath

func (fb *FileBlocker) CheckPath(urlPath string) string

CheckPath returns the blocked extension if urlPath ends with a blocked file extension, or empty string if the request is allowed. Pass r.URL.Path (not the full URL) to avoid matching query-string artefacts.

func (*FileBlocker) ClearAll

func (fb *FileBlocker) ClearAll()

ClearAll removes all blocked extensions. Used by config import "replace" mode.

func (*FileBlocker) Count

func (fb *FileBlocker) Count() int

Count returns the number of blocked extensions.

func (*FileBlocker) List

func (fb *FileBlocker) List() []string

List returns a snapshot of the blocked extensions.

func (*FileBlocker) Remove

func (fb *FileBlocker) Remove(ext string)

Remove deletes an extension from the block list and persists.

func (*FileBlocker) ReplaceAll

func (fb *FileBlocker) ReplaceAll(exts []string)

ReplaceAll atomically replaces the in-memory extension set with the normalised contents of exts and persists once. Per-element semantics mirror Add exactly: lowercase + trim, leading-dot inserted if missing, empty / bare-dot entries skipped, duplicates collapsed by the set.

CL-13: used by applyConfigSnapshot's FileBlockExtensions branch to replace the prior ClearAll + per-extension Add loop, which triggered N+1 atomicWriteFile syscalls per snapshot apply (cap 10_000 extensions per maxSnapFileBlockExtensions). ReplaceAll triggers exactly one save() call regardless of N.

Save is invoked unconditionally — including when the new set is equal to the existing set. Skipping save on equal content would require deep-comparing maps under the lock and add a fast-path that could deviate from the long-term durability guarantee, which is out of CL-13 scope per the user brief.

func (*FileBlocker) SetPath

func (fb *FileBlocker) SetPath(p string)

SetPath configures the persistence file and loads any previously saved extensions from it. If the file doesn't exist, the current in-memory state is kept (caller should load defaults before calling SetPath).

type FileExtProfile

type FileExtProfile struct {
	ID         string   `json:"id"`
	Name       string   `json:"name"`
	Extensions []string `json:"extensions"`
}

FileExtProfile is a named set of file extensions used for per-policy-rule blocking.

type FileProfileStore

type FileProfileStore struct {
	// contains filtered or unexported fields
}

FileProfileStore manages a persistent collection of file extension profiles. All operations are safe for concurrent use.

DURABILITY + PUBLICATION CONTRACT (2D-C.0B):

  • Elements are IMMUTABLE after publication: every mutation builds a NEW slice with NEW element values (copy-on-write pointer swap) — never an in-place field write, which would race the lock-free p.Extensions reads on the enforcement path (FileProfileBlocked reads the returned pointer outside the store lock).
  • Mutations are durable-or-nothing: the TARGET set is persisted FIRST (AtomicWrite) and the in-memory swap happens only after the write landed. A hard persistence failure returns the error with memory (and therefore restart truth) unchanged. fileutil.ErrReplacedNotSynced follows the landed-content doctrine: the file visibly contains the new set, so memory swaps forward and the sentinel is surfaced, never rolled back against landed content.
  • The revision is a CONTENT-DERIVED semantic fingerprint (Revision) over the sorted (id, name, normalized extensions) tuples — restart-stable with no on-disk format migration, which is exactly what a browser optimistic fence needs. A fenced mutation compares the asserted revision INSIDE the same critical section that mutates and persists.

func (*FileProfileStore) Create

func (s *FileProfileStore) Create(name string, exts []string) (*FileExtProfile, error)

Create adds a new profile, durable-or-nothing. ifRevision "" skips the optimistic fence. Returns an error if the name is already taken.

func (*FileProfileStore) CreateFenced added in v1.0.218

func (s *FileProfileStore) CreateFenced(ifRevision, name string, exts []string) (*FileExtProfile, error)

CreateFenced is Create with the v2 optimistic-revision fence: comparison, mutation, and durable publish share this one critical section.

func (*FileProfileStore) Delete

func (s *FileProfileStore) Delete(id string) error

Delete removes a profile by ID, durable-or-nothing. ifRevision "" skips the fence (legacy callers).

func (*FileProfileStore) DeleteFenced added in v1.0.218

func (s *FileProfileStore) DeleteFenced(ifRevision, id string) error

DeleteFenced is Delete with the v2 optimistic-revision fence.

func (*FileProfileStore) GetByID

func (s *FileProfileStore) GetByID(id string) *FileExtProfile

GetByID returns the profile with the given ID, or nil.

func (*FileProfileStore) GetByName

func (s *FileProfileStore) GetByName(name string) *FileExtProfile

GetByName returns the profile with the given name (case-insensitive), or nil.

func (*FileProfileStore) List

func (s *FileProfileStore) List() []*FileExtProfile

List returns a copy of all profiles.

func (*FileProfileStore) Load

func (s *FileProfileStore) Load(path string) error

Load reads profiles from disk. If the file does not exist the built-in profiles are seeded and persisted so policy rules continue to work. A persisted set that fails ValidateProfiles is REFUSED — the store keeps its prior (empty at boot) contents rather than publishing ambiguous identity, and the ID-authoritative enforcement path fails closed on the rules that referenced it.

func (*FileProfileStore) NameByID added in v1.0.72

func (s *FileProfileStore) NameByID(id string) (string, bool)

NameByID returns the profile's name (a value copy, read under the lock) and whether it exists. Callers needing only the name must use this rather than GetByID().Name — GetByID returns the LIVE pointer, and reading .Name off it outside the lock races a concurrent Update (which mutates p.Name in place).

func (*FileProfileStore) ReplaceAll

func (s *FileProfileStore) ReplaceAll(profiles []FileExtProfile) error

ReplaceAll atomically replaces all profiles in memory and persists the new set. FOLLOWER semantics by design (2D-C.0B §12 audit): the ONLY production caller is the CP→DP ConfigSnapshot apply, where the CONTROL PLANE is the source of truth — the in-memory swap is authoritative so the DP enforces the CP's profiles even on a wedged local disk, the persist failure is logged (and observed by the CHAOS-45 durable-write chokepoint), and the next CP sync re-converges. CANDIDATE VALIDITY is separate from that follower durability posture (2D-C final §15): a set failing ValidateProfiles is refused up front — ReplaceAll must never make ambiguous identity authoritative (the snapshot preflight rejects it earlier; this is the defense-in-depth at the store boundary). Local confirmed administrative mutations must NEVER use this path — they go through Create/Update/Delete, which are durable-or-nothing. Tests are the other callers (state seeding).

func (*FileProfileStore) Revision added in v1.0.218

func (s *FileProfileStore) Revision() string

Revision returns the content-derived semantic fingerprint of the committed profile set. Restart-stable: same profiles ⇒ same revision across restarts.

func (*FileProfileStore) Save

func (s *FileProfileStore) Save() error

Save persists the current profile set to the configured path.

func (*FileProfileStore) SetPath

func (s *FileProfileStore) SetPath(p string)

SetPath sets the persistence file path without reading from disk (use Load to also read existing profiles). Added for the package boundary: package main integration tests need to redirect persistence without the whitebox field access they relied on before the move.

func (*FileProfileStore) SnapshotWithRevision added in v1.0.218

func (s *FileProfileStore) SnapshotWithRevision() (profiles []*FileExtProfile, revision string)

SnapshotWithRevision returns the profile rows AND the revision that describes exactly them, captured under ONE read lock (coherent-read doctrine, 2D-A/2D-B): a management GET must never pair rows from one state with another state's fence token. The returned pointers are immutable published values (see the store contract).

func (*FileProfileStore) Update

func (s *FileProfileStore) Update(id, name string, exts []string) error

Update replaces the name and/or extensions of an existing profile, durable-or-nothing (copy-on-write — the published element is never mutated in place). ifRevision "" skips the fence (legacy callers).

func (*FileProfileStore) UpdateFenced added in v1.0.218

func (s *FileProfileStore) UpdateFenced(ifRevision, id, name string, exts []string) error

UpdateFenced is Update with the v2 optimistic-revision fence.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL