Files
phishingclub/backend/lure/codec.go
T
2026-07-27 17:57:53 +02:00

76 lines
3.1 KiB
Go

// Package lure builds and resolves the short recipient identifiers that appear
// in a lure URL, for example https://example.com/account/4H7K9QM2XR3T.
//
// A code is stored and matched byte for byte. Nothing is folded, uppercased or
// stripped, so an operator who picks Special-42 gets exactly that link and it
// stays distinct from special-42. This is required by base58, whose two cases
// are different symbols, and applying the same rule everywhere keeps one
// matching behaviour rather than one per algorithm.
package lure
import "strings"
// Alphabet is the Crockford base32 encoding alphabet. It omits I, L, O and U
// so that a code read aloud or transcribed from a message cannot be confused
// with a visually similar character or spell an unintended word.
const Alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
// Base58Alphabet is the Bitcoin base58 alphabet. It keeps both cases, which
// makes a code shorter for the same number of combinations, and drops the four
// glyphs that are hard to tell apart in print: 0, O, I and l.
const Base58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
// MinLength and MaxLength bound the operator selectable length of a *generated*
// code. The lower bound is a deliberate operator choice for short lived
// campaigns where a guessable link is acceptable.
const (
MinLength = 6
MaxLength = 16
DefaultLength = 12
)
// MaxCustomLength is the only limit placed on an operator written code, and it
// exists because the column is varchar(64).
const MaxCustomLength = 64
// IsCandidate reports whether a path segment or query value could be a lure
// code and is therefore worth a database probe.
//
// Because an operator written code may be any text, this cannot check an
// alphabet. It only rules out what could not be a code at all: anything too
// long for the column, and anything that would have to be escaped to sit in a
// single path segment. A dot is allowed, so a lure can end in something like
// invoice.pdf, at the cost of one indexed probe on static asset requests.
func IsCandidate(s string) bool {
if s == "" || len(s) > MaxCustomLength {
return false
}
// structural URL characters, which would split the segment or need escaping
if strings.ContainsAny(s, "/%?#\\") {
return false
}
// quotes and angle brackets, because the finished URL is written into a
// mail body rendered with text/template, which does not escape it
if strings.ContainsAny(s, "\"'<>") {
return false
}
// control characters and whitespace. a code containing one could never be
// matched back from a request path, so it would be silently unreachable
for _, r := range s {
if r <= 0x20 || r == 0x7f {
return false
}
}
return true
}
// IsValidCustom reports whether an operator supplied code can be stored.
//
// The code is kept byte for byte as written, so the only rules are the ones
// that would otherwise make it unreachable: it has to fit the column and it has
// to survive as a single path segment without escaping. A path with more than
// one segment comes from the template URL path, not from the code.
func IsValidCustom(s string) bool {
return IsCandidate(s)
}