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
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,49 @@ what to check.

### Changed

- Every field refuses invisible characters, not only line breaks: a control
character (ESC, NUL, TAB, DEL, the C1 range) or a format character (bidi
overrides such as U+202E, zero-width space and joiner, BOM) is rejected
with exit 2 and an error naming the field and codepoint, e.g.
`beneficiary name contains a control character U+001B`. Before, such a
value was encoded as given, and `--details` printed it raw — an ESC
sequence in `--text` could rewrite the `iban:` line on the terminal while
the QR carried the real IBAN. Names with emoji joined by U+200D are
affected; a no-break space is not.
- `--details` shows any character without a visible form as its `\uXXXX`
escape instead of the raw byte, so the verification view can never drive
the terminal it is printed on.
- `--iban`, `--bic`, `--purpose` and an RF creditor reference in `--ref`
name a non-ASCII character instead of case-folding it: `ſNPAFRPP`
(U+017F, long s) was silently turned into the BIC `SNPAFRPP`,
`GB82WEſT…` into a valid `GB82WEST…`, and `RF35ſ12345` into the
checksum-valid `RF35S12345`. All are now refused with `contains a
non-ASCII character U+017F ('ſ')`. Plain ASCII lower case still folds as
before, and a reference that is not an RF claim keeps passing through
verbatim, non-ASCII included.
- Web: the QR is inserted as a parsed SVG node instead of an HTML string,
and `web-smoke.mjs` now fails on any HTML string sink in the page sources.
Nothing visible changes; an SVG the page cannot parse shows an error
instead of a stale code.
- `--png` names its scratch file with a random suffix instead of the
process id. A file planted under the old predictable name
(`.out.png.<pid>.tmp`) made the write fail with "file exists"; now it is
left alone and the PNG is written regardless. Permissions are unchanged:
new files get what `os.Create` would, replaced files keep their mode.
- The PNG scale (8 px per module) is one constant, `render.DefaultPNGScale`,
shared by `--png` and the web download; behaviour is unchanged.

### Fixed

- Web: the download filename is taken from the encoded payload, not from the
IBAN field. Rendering is debounced by 150 ms, so a click inside that window
could save `epc-<new IBAN>.svg` containing the previous IBAN's code.
`web-smoke.mjs` gates it.
- Web: the privacy note no longer says browsers "never send" the #fragment
"to any server". They do not send it in requests, but the link lands in
the browser history like any URL and travels with history sync and
address-bar suggestions. The footer (EN/DE) and README "Web" say so.

## [0.2.0] - 2026-09-04

### Changed
Expand Down
11 changes: 9 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ asks for and why.
| `cmd/epcii-wasm` | `js && wasm` entry point exposing `internal/webapi` as `globalThis.epcii` |
| `web/` | Static page (HTML/JS/CSS, no framework, no external resources); `web/dist/` is the gitignored build output |
| `scripts/build-web.sh` | Assembles `web/dist/` (page files, Go's `wasm_exec.js`, the wasm build) |
| `scripts/web-smoke.mjs` | Node gate: the wasm build's SVG/PNG must equal the CLI's byte for byte |
| `scripts/web-smoke.mjs` | Node gate: the wasm build's SVG/PNG must equal the CLI's byte for byte; also greps `web/` for storage APIs, address-bar writes, external resources and HTML string sinks, and pins that `fileStem()` reads the encoded result, not the form |
| `scripts/gen_segno_fixtures.py` | One-shot generator for the segno golden fixtures in `internal/epc/testdata/` |
| `scripts/qrfixtures/` | Separate Go module: regenerates the upstream matrix fingerprints in `internal/qr/testdata/` from piglig/go-qr |

Expand Down Expand Up @@ -121,7 +121,14 @@ allows CRLF too). IBANs outside the SEPA country table are rejected, not
merely mod-97 checked: the payload initiates a SEPA credit transfer, which
cannot reach them. Text fields are not NFC-normalized (that would pull
`golang.org/x/text` into the binary); decomposed input counts every
combining mark as a character, and the length error says so.
combining mark as a character, and the length error says so. No field may
carry an invisible character: control characters (Unicode `Cc`, which
includes ESC, NUL and TAB, not only CR/LF) and format characters (`Cf`:
bidi overrides, zero-width space and joiner, BOM) are rejected with the
field and codepoint named. An ESC would drive the terminal that displays
the `--details` view; a bidi override would make a beneficiary name read
differently from how it is stored. Visible whitespace such as the no-break
space stays legal.

## Issues and PRs

Expand Down
19 changes: 12 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,15 @@ What the page does not do:
no further request. (GitHub keeps ordinary access logs for the page load
itself, like any web host.)
- **Share links stay in the fragment.** "Copy link" and "Share" build a URL
of the form `…/epcii/#v=1&name=…&iban=…&amount=…`; browsers never send the
`#fragment` to a server, so payment data does not reach GitHub's logs or a
`Referer`. The address bar is never written automatically — a link exists
only when you ask for one, and opening one fills the form and renders.
Whoever opens such a link has it in their own browser history, as with any
URL; the page cannot prevent that.
of the form `…/epcii/#v=1&name=…&iban=…&amount=…`; browsers do not send
the `#fragment` in requests, so payment data does not reach GitHub's logs
or a `Referer`. The address bar is never written automatically — a link
exists only when you ask for one, and opening one fills the form and
renders. The link itself is an ordinary URL, though: whoever opens it has
it in their browser history, and a browser that syncs history or feeds the
address bar to a search provider treats it like any other address. The
page cannot prevent that; share such a link as you would share the
payment data itself.
- **Content Security Policy.** Pages cannot send HTTP headers, so the policy
is a `<meta>` tag: `default-src 'none'`, scripts and styles only from the
page's own origin, no inline script. Directives that a meta CSP cannot
Expand Down Expand Up @@ -151,7 +154,9 @@ upstream piglig/go-qr encoder (`go run -C scripts/qrfixtures .`, a separate
module so upstream never enters `go.mod`). The web build adds a fifth:
`scripts/web-smoke.mjs` runs the wasm through Go's `wasm_exec.js` and
compares its SVG and PNG with the CLI byte for byte, then greps the page
sources for storage APIs, address-bar writes and external resources.
sources for storage APIs, address-bar writes, external resources and HTML
string sinks, and pins that the download name comes from the encoded
payload rather than the form.

## Contributing

Expand Down
75 changes: 63 additions & 12 deletions internal/epc/epc.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import (
const MaxPayloadBytes = 331

// Payment holds the beneficiary and transfer data for one EPC QR payload.
// All strings are UTF-8; none may contain line breaks.
// All strings are UTF-8; none may contain a line break, a control character
// or an invisible format character (see Payload).
type Payment struct {
Name string // beneficiary name, required, <=70 chars
IBAN string // beneficiary IBAN, required, mod-97 validated
Expand All @@ -40,7 +41,10 @@ func (p Payment) Payload() (string, error) {
return "", err
}

bic := strings.ToUpper(strings.ReplaceAll(p.BIC, " ", ""))
bic, err := asciiUpper("BIC", strings.ReplaceAll(p.BIC, " ", ""))
if err != nil {
return "", err
}
if bic != "" {
if err := validateBIC(bic); err != nil {
return "", err
Expand All @@ -57,7 +61,10 @@ func (p Payment) Payload() (string, error) {
}

// EPC069-12 section 2.2: purpose (AT-T007) is 1..4 alphanumeric.
purpose := strings.ToUpper(strings.TrimSpace(p.Purpose))
purpose, err := asciiUpper("purpose code", strings.TrimSpace(p.Purpose))
if err != nil {
return "", err
}
if len(purpose) > 4 {
return "", fmt.Errorf("purpose code must be at most 4 characters, got %q", purpose)
}
Expand All @@ -76,7 +83,13 @@ func (p Payment) Payload() (string, error) {
// IBAN. Every other structured reference belongs to an issuer's own
// scheme, where case and inner spacing may carry meaning, so it passes
// through untouched.
if norm := strings.ToUpper(strings.ReplaceAll(ref, " ", "")); isISO11649Claim(norm) {
if stripped := strings.ReplaceAll(ref, " ", ""); isISO11649Claim(strings.ToUpper(stripped)) {
// An RF claim is ASCII by definition: a homoglyph is named before the
// fold instead of becoming a different, checksum-valid reference.
norm, err := asciiUpper("structured reference", stripped)
if err != nil {
return "", err
}
ref = norm
if err := validateCreditorReference(ref); err != nil {
return "", err
Expand All @@ -100,14 +113,14 @@ func (p Payment) Payload() (string, error) {
{"version", "002"},
{"character set", "1"}, // 1 = UTF-8
{"identification", "SCT"},
{"bic", bic}, // AT-C002, optional in version 002 (EEA)
{"name", name}, // AT-E001 beneficiary name
{"iban", iban}, // AT-C001 beneficiary IBAN
{"amount", amount}, // AT-T002, optional
{"purpose", purpose}, // AT-T007, optional
{"ref", ref}, // AT-T009 structured remittance (exclusive with text)
{"text", p.Text}, // AT-T009 unstructured remittance
{"info", p.Info}, // beneficiary-to-originator information
{"BIC", bic}, // AT-C002, optional in version 002 (EEA)
{"beneficiary name", name}, // AT-E001
{"IBAN", iban}, // AT-C001
{"amount", amount}, // AT-T002, optional
{"purpose code", purpose}, // AT-T007, optional
{"structured reference", ref}, // AT-T009 structured remittance (exclusive with text)
{"remittance text", p.Text}, // AT-T009 unstructured remittance
{"beneficiary-to-originator info", p.Info}, // the labels match tooLong's, so every error names a field the same way
}
values := make([]string, len(fields))
for i, f := range fields {
Expand All @@ -119,6 +132,14 @@ func (p Payment) Payload() (string, error) {
if !utf8.ValidString(f.value) {
return "", fmt.Errorf("%s is not valid UTF-8", f.name)
}
// Nothing invisible may enter a payment field. A control character
// (ESC above all) drives the terminal that displays the --details
// verification view; a format character — bidi override, zero-width
// space, BOM — makes a beneficiary name read differently from how it
// is stored. EPC069-12 does not contemplate either in these fields.
if r, kind := invisibleRune(f.value); kind != "" {
return "", fmt.Errorf("%s contains %s U+%04X", f.name, kind, r)
}
values[i] = f.value
}

Expand Down Expand Up @@ -150,6 +171,36 @@ func hasCombiningMarks(s string) bool {
return false
}

// invisibleRune returns the first control (Cc) or format (Cf) rune in s with
// a description for the error message, or kind "" when there is none.
// Whitespace other than CR/LF/TAB is not invisible in this sense: a no-break
// space shows as a space and IBAN normalization strips it anyway.
func invisibleRune(s string) (rune, string) {
for _, r := range s {
switch {
case unicode.IsControl(r):
return r, "a control character"
case unicode.Is(unicode.Cf, r):
return r, "an invisible format character"
}
}
return 0, ""
}

// asciiUpper upper-cases an identifier that is ASCII by definition — BIC,
// purpose code, IBAN. Anything outside ASCII is refused by name before any
// folding: strings.ToUpper turns U+017F (long s) into S and U+0131 (dotless
// i) into I, so a pasted homoglyph would otherwise be validated — and, for
// the checksum-free BIC, encoded — as a value the user never typed.
func asciiUpper(field, s string) (string, error) {
for _, r := range s {
if r > unicode.MaxASCII {
return "", fmt.Errorf("%s contains a non-ASCII character U+%04X (%q); only A-Z and 0-9 are valid", field, r, r)
}
}
return strings.ToUpper(s), nil
}

// tooLong reports a character-limit violation for a text field.
func tooLong(field, s string, limit int) error {
n := utf8.RuneCountInString(s)
Expand Down
134 changes: 134 additions & 0 deletions internal/epc/epc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package epc
import (
"strings"
"testing"
"unicode"
)

func TestValidateIBAN(t *testing.T) {
Expand Down Expand Up @@ -31,6 +32,7 @@ func TestValidateIBAN(t *testing.T) {

invalid := map[string]string{
"": "empty",
"GB82WE\u017fT12345698765432": "U+017F (long s) folds to S under ToUpper; a homoglyph must be named, never accepted",
"DE02120300000000202052": "wrong check digit",
"DE0212030000000020205": "21 chars: the DE length rule (22) rejects it before mod-97 runs",
"DE0": "too short to carry check digits",
Expand All @@ -56,6 +58,58 @@ func TestValidateIBAN(t *testing.T) {
if _, err := ValidateIBAN("SA0380000000608010167519"); err == nil || !strings.Contains(err.Error(), "SEPA") {
t.Errorf("non-SEPA IBAN: error must name SEPA membership, got %v", err)
}
// A non-ASCII letter must be named as such before any case folding: the
// generic ToUpper turns U+017F into S and U+0131 into I, so the IBAN
// would otherwise be validated in a form the user never typed.
if _, err := ValidateIBAN("GB82WE\u017fT12345698765432"); err == nil || !strings.Contains(err.Error(), "U+017F") {
t.Errorf("homoglyph IBAN: error must name the non-ASCII character, got %v", err)
}
}

// TestPayloadRejectsNonASCIIBIC: BIC and purpose code are ASCII by
// definition and, unlike the IBAN, carry no checksum. The generic ToUpper
// silently folded U+017F to S, so a pasted homoglyph became a different,
// plausible BIC with no warning.
func TestPayloadRejectsNonASCIIBIC(t *testing.T) {
const iban = "DE02120300000000202051"
cases := map[string]Payment{
"long s in BIC": {Name: "X", IBAN: iban, BIC: "\u017fNPAFRPP"},
"dotless i in BIC": {Name: "X", IBAN: iban, BIC: "BNPAFRPP\u0131XX"},
"long s in purpose": {Name: "X", IBAN: iban, Purpose: "\u017fALA"},
"Kelvin sign in BIC": {Name: "X", IBAN: iban, BIC: "\u212aOBADEFF"}, // U+212A folds to K under ToUpper/ToLower
"fullwidth letter, BIC": {Name: "X", IBAN: iban, BIC: "\uff22NPAFRPP"},
// An RF claim is folded and mod-97 checked; RF35S12345 passes that
// check, so the long s would have produced a valid reference the
// user never typed.
"long s in RF reference": {Name: "X", IBAN: iban, Ref: "RF35\u017f12345"},
}
for name, p := range cases {
payload, err := p.Payload()
if err == nil {
t.Errorf("%s: accepted, payload %q", name, payload)
continue
}
if !strings.Contains(err.Error(), "U+") {
t.Errorf("%s: error %q must name the non-ASCII codepoint", name, err)
}
}
// Plain ASCII lower case still folds: the rule is about non-ASCII, not
// about case.
p := Payment{Name: "X", IBAN: iban, BIC: "bnpafrpp", Purpose: "gdds"}
payload, err := p.Payload()
if err != nil {
t.Fatalf("ASCII lower-case BIC and purpose must still be accepted: %v", err)
}
if !strings.Contains(payload, "\nBNPAFRPP\n") || !strings.Contains(payload, "\nGDDS") {
t.Errorf("ASCII folding lost: %q", payload)
}
// A reference that is not an RF claim belongs to an issuer's scheme and
// is never folded, so non-ASCII is legal there and must stay verbatim.
issuer := Payment{Name: "X", IBAN: iban, Ref: "Rechnung Müller 2026"}
payload, err = issuer.Payload()
if err != nil || !strings.Contains(payload, "\nRechnung Müller 2026") {
t.Errorf("non-RF reference with non-ASCII must pass through verbatim: %q, %v", payload, err)
}
}

func TestNormalizeAmount(t *testing.T) {
Expand Down Expand Up @@ -471,3 +525,83 @@ func TestValidateBIC(t *testing.T) {
t.Error("Payload accepted the placeholder BIC 00000000")
}
}

// TestPayloadRejectsInvisibleCharacters: only CR and LF used to be refused,
// so every other control character reached the payload and the --details
// view verbatim (ESC drives the terminal that shows it), and bidi overrides
// or zero-width runs could make a beneficiary name read differently from
// how it is stored. Each rejection names the field and the codepoint.
func TestPayloadRejectsInvisibleCharacters(t *testing.T) {
const iban = "DE02120300000000202051"
cases := map[string]struct {
p Payment
want string // field and codepoint the error must name
}{
"ESC in name": {Payment{Name: "Alice\x1b[31mEVIL", IBAN: iban}, "beneficiary name contains a control character U+001B"},
"NUL in text": {Payment{Name: "X", IBAN: iban, Text: "paid\x00"}, "remittance text contains a control character U+0000"},
"TAB in name": {Payment{Name: "ACME\tGmbH", IBAN: iban}, "U+0009"},
"BEL in info": {Payment{Name: "X", IBAN: iban, Info: "ring\a"}, "U+0007"},
"DEL in text": {Payment{Name: "X", IBAN: iban, Text: "x\x7fy"}, "U+007F"},
"C1 control in name": {Payment{Name: "A\u0085B", IBAN: iban}, "U+0085"},
"RLO in name": {Payment{Name: "ACME\u202e GmbH", IBAN: iban}, "beneficiary name contains an invisible format character U+202E"},
"LRI in text": {Payment{Name: "X", IBAN: iban, Text: "invoice\u2066 42"}, "U+2066"},
"PDI in text": {Payment{Name: "X", IBAN: iban, Text: "42\u2069"}, "U+2069"},
"zero-width space": {Payment{Name: "X", IBAN: iban, Text: "in\u200bvoice"}, "U+200B"},
"BOM in info": {Payment{Name: "X", IBAN: iban, Info: "\ufeffinfo"}, "U+FEFF"},
"zero-width joiner": {Payment{Name: "A\u200dB", IBAN: iban}, "U+200D"},
"ESC in non-RF ref": {Payment{Name: "X", IBAN: iban, Ref: "INV\x1b[2K"}, "structured reference contains a control character U+001B"},
"escape in purpose code": {Payment{Name: "X", IBAN: iban, Purpose: "A\x1bB"}, ""}, // rejected by the alphanumeric rule; any error will do
}
for name, tc := range cases {
payload, err := tc.p.Payload()
if err == nil {
t.Errorf("%s: accepted, payload %q", name, payload)
continue
}
if !strings.Contains(err.Error(), tc.want) {
t.Errorf("%s: error %q must name the field and codepoint (%q)", name, err, tc.want)
}
}

// Visible whitespace and decomposed text stay legal: the rule is about
// characters that do not show, not about anything non-ASCII.
good := map[string]Payment{
"no-break space in name": {Name: "ACME\u00a0GmbH", IBAN: iban},
"combining mark in name": {Name: "Mu\u0308ller", IBAN: iban},
"narrow no-break in text": {Name: "X", IBAN: iban, Text: "1\u202f234"},
"tab inside the IBAN only": {Name: "X", IBAN: "DE02\t1203 0000 0000 2020 51"}, // whitespace is stripped before the check
}
for name, p := range good {
if _, err := p.Payload(); err != nil {
t.Errorf("%s: unexpected error: %v", name, err)
}
}
}

// FuzzPayload pins the shape invariant behind the field gate: whatever the
// inputs, an accepted payload has at most twelve LF-separated lines and
// carries no control or format character other than the separators. The
// seeds are the hostile inputs from the 2026-09-21 audit.
func FuzzPayload(f *testing.F) {
const iban = "DE02120300000000202051"
f.Add("ACME GmbH", iban, "", "12,50", "", "", "invoice 42", "")
f.Add("Alice\x1b[31mEVIL", iban, "", "", "", "", "", "")
f.Add("X", iban, "", "", "", "", "paid\x1b[2A\x1b[2Kiban: DE00SPOOFED", "")
f.Add("ACME\u202e GmbH", iban, "", "", "", "", "invoice\u2066 42\u2069\u200b", "\ufeffinfo")
f.Add("X", iban, "BNPAFRPP", "0.01", "GDDS", "RF18539007547034", "", "")
f.Fuzz(func(t *testing.T, name, iban, bic, amount, purpose, ref, text, info string) {
p := Payment{Name: name, IBAN: iban, BIC: bic, Amount: amount, Purpose: purpose, Ref: ref, Text: text, Info: info}
payload, err := p.Payload()
if err != nil {
return
}
if n := strings.Count(payload, "\n"); n > 11 {
t.Fatalf("accepted payload has %d lines, EPC069-12 has 12 fields:\n%q", n+1, payload)
}
for _, r := range payload {
if r != '\n' && (unicode.IsControl(r) || unicode.Is(unicode.Cf, r)) {
t.Fatalf("accepted payload carries U+%04X:\n%q", r, payload)
}
}
})
}
Loading
Loading