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
2 changes: 1 addition & 1 deletion .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ linters:
disabled: false
arguments:
- max-lit-count: "3"
allow-strs: '"","-","windows","alice","data","HS256","app","apps","sectools","hello","password","secret","stream","0xFF","0xFE","0xFD"'
allow-strs: '"","-","%w: %w","windows","alice","data","HS256","app","apps","sectools","hello","password","secret","stream","0xFF","0xFE","0xFD"'
allow-ints: "-1,0,1,2,10,1024,0o600,0o644,0o700,0o755,0666,0755,0xFF,0xFE,0xFD,0o004"
allow-floats: "0.0,1.0"

Expand Down
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Security-focused Go helpers for file I/O, in-memory handling of sensitive data,
- Email and URL validation with optional DNS/redirect/reputation checks
- Random token generation and validation with entropy/length caps
- Bounded base64/hex encoding and strict JSON decoding
- Size-bounded JSON/YAML/XML parsing helpers
- Redaction helpers and secret detection heuristics for logs/config dumps
- Opinionated TLS configs with TLS 1.2/1.3 defaults, mTLS, and optional post-quantum key exchange
- HTML/Markdown sanitization, SQL/NoSQL input guards, and filename sanitizers
Expand Down Expand Up @@ -340,7 +341,29 @@ func main() {
Name string `json:"name"`
}

_ = encoding.DecodeJSON([]byte(`{"name":"alpha"}`), &payload{})
_ = encoding.DecodeJSON([]byte(`{"name":"alpha"}`), &payload{})
}
```

### Parsing limits

```go
package main

import (
"strings"

"github.com/hyp3rd/sectools/pkg/limits"
)

func main() {
var payload map[string]any

reader := strings.NewReader(`{"name":"alpha"}`)
err := limits.DecodeJSON(reader, &payload, limits.WithMaxBytes(1<<20))
if err != nil {
panic(err)
}
}
```

Expand Down
6 changes: 6 additions & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@
"memprofile",
"mfa",
"MFA",
"MiB",
"mkdocs",
"mlkem",
"mlock",
Expand Down Expand Up @@ -181,6 +182,7 @@
"SBOM",
"SBOMs",
"sectauth",
"sectencoding",
"sectio",
"sectools",
"securego",
Expand Down Expand Up @@ -223,6 +225,10 @@
"Wrapf",
"x509",
"xlarge",
"xml",
"XML",
"yaml",
"YAML",
"zeroization",
"zeroized",
"zeroizes"
Expand Down
5 changes: 5 additions & 0 deletions docs/security-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ This checklist is a quick reference for teams using sectools in production.
- Use `pkg/encoding` for base64/hex decoding with length caps.
- Keep JSON decoding strict and set `WithJSONMaxBytes` for untrusted payloads.

## Parsing Limits

- Use `pkg/limits` for size-bounded JSON/YAML/XML parsing.
- Enforce conservative max byte limits for untrusted payloads.

## Secrets

- Redact structured logs with `pkg/secrets` before writing sensitive fields.
Expand Down
30 changes: 30 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ supporting implementations in `internal/`.
- `pkg/validate`: email and URL validation helpers.
- `pkg/tokens`: random token generation and validation helpers.
- `pkg/encoding`: bounded encoding and decoding helpers.
- `pkg/limits`: size-bounded parsing helpers for common formats.
- `pkg/secrets`: redaction and secret detection helpers.
- `pkg/tlsconfig`: opinionated TLS configuration helpers.
- `pkg/sanitize`: HTML/Markdown sanitizers, SQL input guards, and filename sanitizers.
Expand Down Expand Up @@ -459,6 +460,35 @@ Behavior:
- Rejects payloads larger than the configured max size.
- Rejects trailing data after the first JSON value.

## pkg/limits

### Size-bounded decoding

```go
func ReadAll(reader io.Reader, opts ...Option) ([]byte, error)
func DecodeJSON(reader io.Reader, value any, opts ...Option) error
func DecodeYAML(reader io.Reader, value any, opts ...Option) error
func DecodeXML(reader io.Reader, value any, opts ...Option) error
```

Behavior:

- Enforces a maximum input size (default 1 MiB).
- Rejects inputs that exceed the configured limit.
- YAML decoding rejects unknown fields by default (override with `WithYAMLAllowUnknownFields(true)`).

Example:

```go
import "github.com/hyp3rd/sectools/pkg/limits"

var payload map[string]any
err := limits.DecodeJSON(reader, &payload, limits.WithMaxBytes(1<<20))
if err != nil {
panic(err)
}
```

## pkg/secrets

### Secret detection
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ require (
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.47.0
golang.org/x/net v0.49.0
gopkg.in/yaml.v3 v3.0.1
)

require (
Expand All @@ -21,5 +22,4 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
4 changes: 2 additions & 2 deletions pkg/auth/jwt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ func TestJWTSignerMissingExpiration(t *testing.T) {

func TestJWTSignVerifyRoundTrip(t *testing.T) {
t.Parallel()
//nolint:revive
now := time.Date(2024, 10, 1, 12, 0, 0, 0, time.UTC)

now := time.Date(2024, 10, 1, 12, 0, 0, 0, time.UTC) //nolint:revive
secret := []byte("supersecret")

signer, err := NewJWTSigner(
Expand Down
2 changes: 2 additions & 0 deletions pkg/limits/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Package limits provides size-bounded parsing helpers for common formats.
package limits
16 changes: 16 additions & 0 deletions pkg/limits/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package limits

import "github.com/hyp3rd/ewrap"

var (
// ErrInvalidLimitConfig indicates an invalid limits configuration.
ErrInvalidLimitConfig = ewrap.New("invalid limits config")
// ErrInvalidLimitInput indicates the input or target is invalid.
ErrInvalidLimitInput = ewrap.New("invalid limits input")
// ErrLimitExceeded indicates the input exceeded the configured limit.
ErrLimitExceeded = ewrap.New("input exceeds limit")
// ErrReadFailed indicates the input could not be read.
ErrReadFailed = ewrap.New("input read failed")
// ErrDecodeFailed indicates the input could not be decoded.
ErrDecodeFailed = ewrap.New("input decode failed")
)
188 changes: 188 additions & 0 deletions pkg/limits/limits.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
package limits

import (
"bytes"
"encoding/xml"
"errors"
"fmt"
"io"

"gopkg.in/yaml.v3"

"github.com/hyp3rd/sectools/pkg/converters"
sectencoding "github.com/hyp3rd/sectools/pkg/encoding"
)

const (
limitsDefaultMaxBytes = 1 << 20
)

// Option configures size limits and decoding behavior.
type Option func(*config) error

type config struct {
maxBytes int
yamlAllowUnknownFields bool
}

// ReadAll reads the entire input, enforcing the configured size limit.
func ReadAll(reader io.Reader, opts ...Option) ([]byte, error) {
cfg, err := resolveConfig(opts)
if err != nil {
return nil, err
}

return readAll(reader, cfg.maxBytes)
}

// DecodeJSON decodes JSON with size bounds and strict defaults.
func DecodeJSON(reader io.Reader, value any, opts ...Option) error {
cfg, err := resolveConfig(opts)
if err != nil {
return err
}

if reader == nil || value == nil {
return ErrInvalidLimitInput
}

data, err := readAll(reader, cfg.maxBytes)
if err != nil {
return err
}

return sectencoding.DecodeJSON(data, value, sectencoding.WithJSONMaxBytes(cfg.maxBytes))
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The call to sectencoding.DecodeJSON with WithJSONMaxBytes(cfg.maxBytes) is redundant. The data has already been read with size limits enforced by readAll(reader, cfg.maxBytes), and the size check will occur again in the encoding package's DecodeJSON. Since sectencoding.DecodeJSON accepts a byte slice (not a reader), passing the maxBytes option serves no additional purpose and may cause confusion. Consider removing the WithJSONMaxBytes option from this call.

Suggested change
return sectencoding.DecodeJSON(data, value, sectencoding.WithJSONMaxBytes(cfg.maxBytes))
return sectencoding.DecodeJSON(data, value)

Copilot uses AI. Check for mistakes.
}

// DecodeYAML decodes YAML with size bounds.
// Unknown fields are rejected by default unless WithYAMLAllowUnknownFields(true) is set.
func DecodeYAML(reader io.Reader, value any, opts ...Option) error {
cfg, err := resolveConfig(opts)
if err != nil {
return err
}

if reader == nil || value == nil {
return ErrInvalidLimitInput
}

data, err := readAll(reader, cfg.maxBytes)
if err != nil {
return err
}

decoder := yaml.NewDecoder(bytes.NewReader(data))
decoder.KnownFields(!cfg.yamlAllowUnknownFields)

err = decoder.Decode(value)
if err != nil {
return fmt.Errorf("%w: %w", ErrDecodeFailed, err)
}

var extra any

err = decoder.Decode(&extra)
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}

return fmt.Errorf("%w: %w", ErrDecodeFailed, err)
}

return ErrDecodeFailed
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error returned when multiple YAML documents are detected (line 93) should include a descriptive message to maintain consistency with other error handling patterns in this function. Consider wrapping it with a formatted error message, for example: return fmt.Errorf("%w: multiple documents detected", ErrDecodeFailed)

Suggested change
return ErrDecodeFailed
return fmt.Errorf("%w: multiple documents detected", ErrDecodeFailed)

Copilot uses AI. Check for mistakes.
}

// DecodeXML decodes XML with size bounds.
func DecodeXML(reader io.Reader, value any, opts ...Option) error {
cfg, err := resolveConfig(opts)
if err != nil {
return err
}

if reader == nil || value == nil {
return ErrInvalidLimitInput
}

data, err := readAll(reader, cfg.maxBytes)
if err != nil {
return err
}

err = xml.Unmarshal(data, value)
if err != nil {
return fmt.Errorf("%w: %w", ErrDecodeFailed, err)
}

return nil
}

// WithMaxBytes sets the maximum allowed input size in bytes.
func WithMaxBytes(maxBytes int) Option {
return func(cfg *config) error {
if maxBytes <= 0 {
return ErrInvalidLimitConfig
}

cfg.maxBytes = maxBytes

return nil
}
}

// WithYAMLAllowUnknownFields permits unknown YAML fields during decode.
func WithYAMLAllowUnknownFields(allow bool) Option {
return func(cfg *config) error {
cfg.yamlAllowUnknownFields = allow

return nil
}
}

func resolveConfig(opts []Option) (config, error) {
cfg := config{
maxBytes: limitsDefaultMaxBytes,
yamlAllowUnknownFields: false,
}

for _, opt := range opts {
if opt == nil {
continue
}

err := opt(&cfg)
if err != nil {
return config{}, err
}
}

if cfg.maxBytes <= 0 {
return config{}, ErrInvalidLimitConfig
}

return cfg, nil
}

func readAll(reader io.Reader, maxBytes int) ([]byte, error) {
if reader == nil {
return nil, ErrInvalidLimitInput
}

maxBytes64, err := converters.ToInt64(maxBytes)
if err != nil || maxBytes64 <= 0 {
return nil, ErrInvalidLimitConfig
}

limited := io.LimitReader(reader, maxBytes64+1)

data, err := io.ReadAll(limited)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrReadFailed, err)
}

if len(data) > maxBytes {
return nil, ErrLimitExceeded
}

return data, nil
}
Loading
Loading