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
87 changes: 87 additions & 0 deletions internal/helpers/json.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package helpers

import (
"bytes"
"encoding/json"
"fmt"
)

// MarshalJSONWithoutHTMLEscape serializes v to JSON with HTML escaping
// disabled. This function MUST be used for all upstream request serialization
// instead of json.Marshal.
//
// # Background
//
// Go's encoding/json package escapes HTML special characters by default:
//
// < → \u003c
// > → \u003e
// & → \u0026
//
// This behavior is documented in json.Marshal:
// "String values encode as JSON strings coercing invalid UTF-8 and replacing
// HTML characters <, >, and & with \u003c, \u003e, and \u0026 so that the JSON
// will be safe to embed inside HTML <script> tags."
//
// # Root Cause
//
// steemd (the Steem blockchain node) uses the FC (Fast Compiling) library's
// JSON parser, which does NOT understand \uXXXX Unicode escape sequences.
// When FC's parseEscape() encounters a backslash, it reads the next character
// and handles only a limited set: \t, \n, \r, \\, and a few others. For any
// other character (including 'u'), it returns the literal character without
// interpreting the escape.
//
// This means \u003e is parsed as five literal characters: 'u', '0', '0', '3', 'e',
// instead of the single character '>'.
//
// # Impact
//
// When jussi-next forwards a broadcast_transaction request to steemd, if the
// transaction body contains '>' (common in Markdown blockquotes), Go's
// json.Marshal would escape it to \u003e. steemd's FC parser would then see
// 'u003e' instead of '>'. The body content no longer matches what was signed
// by the client, causing signature verification to fail with:
//
// "Missing Posting Authority"
//
// This bug only affects posts/comments that contain HTML special characters.
// Short comments without these characters are unaffected.
//
// # Why jussi-legacy (Python) worked
//
// Python's json.dumps() does NOT escape < > & by default, so the literal
// characters were preserved end-to-end.
//
// # Trailing newline handling
//
// json.Encoder.Encode() appends a trailing '\n' for stream-friendliness
// (so multiple encoded values can be concatenated and still parsed correctly).
// We strip this newline with bytes.TrimSuffix to match json.Marshal's
// behavior and ensure byte-exact payloads, which is important for:
//
// - Content-Length calculation
// - Signature verification (any byte difference breaks the signature)
// - Consistency with json.Marshal expectations throughout the codebase
//
// # Usage
//
// Use this function for ALL upstream request serialization. Do NOT use
// json.Marshal for request bodies that will be sent to steemd.
//
// // Correct
// body, err := helpers.MarshalJSONWithoutHTMLEscape(payload)
//
// // Incorrect - will break transaction signatures
// body, err := json.Marshal(payload)
func MarshalJSONWithoutHTMLEscape(v interface{}) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return nil, fmt.Errorf("failed to marshal JSON: %w", err)
}
// json.Encoder.Encode appends a trailing newline for stream-friendliness.
// Strip it to match json.Marshal behavior and keep byte-exact payloads.
return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil
}
175 changes: 175 additions & 0 deletions internal/helpers/json_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package helpers

import (
"encoding/json"
"testing"
)

func TestMarshalJSONWithoutHTMLEscape(t *testing.T) {
tests := []struct {
name string
input interface{}
wantErr bool
checkFn func(t *testing.T, got []byte)
}{
{
name: "basic map without HTML chars",
input: map[string]interface{}{
"jsonrpc": "2.0",
"method": "call",
"id": 1,
},
wantErr: false,
checkFn: func(t *testing.T, got []byte) {
// Should parse successfully
var result map[string]interface{}
if err := json.Unmarshal(got, &result); err != nil {
t.Fatalf("failed to unmarshal result: %v", err)
}
if result["method"] != "call" {
t.Errorf("method = %v, want call", result["method"])
}
},
},
{
name: "markdown body with greater-than sign",
input: map[string]interface{}{
"params": []interface{}{
"network_broadcast_api",
"broadcast_transaction_synchronous",
map[string]interface{}{
"body": "> **Key Observation**\n> Some quote",
},
},
},
wantErr: false,
checkFn: func(t *testing.T, got []byte) {
// Critical: '>' must NOT be escaped to \u003e
if contains(got, []byte(`\u003e`)) {
t.Errorf("output contains \\u003e escape; '>' should remain literal")
}
// Verify literal '>' is present
if !contains(got, []byte(`">`)) {
t.Errorf("literal '>' not found in output")
}
},
},
{
name: "body with less-than sign",
input: map[string]interface{}{
"body": "5 < 10",
},
wantErr: false,
checkFn: func(t *testing.T, got []byte) {
if contains(got, []byte(`\u003c`)) {
t.Errorf("output contains \\u003c escape; '<' should remain literal")
}
},
},
{
name: "body with ampersand",
input: map[string]interface{}{
"body": "A & B",
},
wantErr: false,
checkFn: func(t *testing.T, got []byte) {
if contains(got, []byte(`\u0026`)) {
t.Errorf("output contains \\u0026 escape; '&' should remain literal")
}
},
},
{
name: "combined HTML special chars",
input: map[string]interface{}{
"body": "<div>Hello & Welcome></div>",
},
wantErr: false,
checkFn: func(t *testing.T, got []byte) {
// None of the chars should be escaped
if contains(got, []byte(`\u003c`)) || contains(got, []byte(`\u003e`)) || contains(got, []byte(`\u0026`)) {
t.Errorf("HTML special chars were escaped; should remain literal")
}
},
},
{
name: "nil input",
input: nil,
wantErr: false,
checkFn: func(t *testing.T, got []byte) {
if string(got) != "null" {
t.Errorf("nil serialized to %q, want null", string(got))
}
},
},
{
name: "no trailing newline",
input: map[string]interface{}{
"test": "value",
},
wantErr: false,
checkFn: func(t *testing.T, got []byte) {
if len(got) > 0 && got[len(got)-1] == '\n' {
t.Errorf("output ends with newline; should be stripped")
}
},
},
{
name: "byte-exact match with json.Marshal for non-HTML",
input: map[string]interface{}{
"a": "hello",
"b": 123,
"c": true,
},
wantErr: false,
checkFn: func(t *testing.T, got []byte) {
// For data without HTML chars, output should match json.Marshal
want, err := json.Marshal(map[string]interface{}{
"a": "hello",
"b": 123,
"c": true,
})
if err != nil {
t.Fatalf("json.Marshal failed: %v", err)
}
if string(got) != string(want) {
t.Errorf("output mismatch\ngot: %s\nwant: %s", got, want)
}
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := MarshalJSONWithoutHTMLEscape(tt.input)
if (err != nil) != tt.wantErr {
t.Fatalf("MarshalJSONWithoutHTMLEscape() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.checkFn != nil {
tt.checkFn(t, got)
}
})
}
}

// contains checks if haystack contains needle as a substring.
func contains(haystack, needle []byte) bool {
return len(haystack) >= len(needle) && indexOf(haystack, needle) >= 0
}

// indexOf returns the index of the first occurrence of needle in haystack,
// or -1 if not found.
func indexOf(haystack, needle []byte) int {
for i := 0; i <= len(haystack)-len(needle); i++ {
match := true
for j := 0; j < len(needle); j++ {
if haystack[i+j] != needle[j] {
match = false
break
}
}
if match {
return i
}
}
return -1
}
10 changes: 8 additions & 2 deletions internal/upstream/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"net/http"
"time"

"github.com/steemit/jussi/internal/helpers"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
)
Expand Down Expand Up @@ -65,8 +66,13 @@ func NewHTTPClient() *HTTPClient {
// is dangerous for non-idempotent requests (e.g. broadcast_transaction)
// and adds latency for all requests.
func (c *HTTPClient) Request(ctx context.Context, url string, payload map[string]interface{}, headers map[string]string) (map[string]interface{}, error) {
// Marshal payload
body, err := json.Marshal(payload)
// Marshal payload with HTML escaping disabled.
// Go's json.Marshal escapes HTML special chars (<, >, &) to \uXXXX by
// default. steemd's FC JSON parser does not understand \u escapes, so
// a body containing '>' would be received as 'u003e', breaking transaction
// signatures. Using json.Encoder with SetEscapeHTML(false) preserves the
// literal characters.
body, err := helpers.MarshalJSONWithoutHTMLEscape(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
Expand Down
5 changes: 4 additions & 1 deletion internal/ws/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"github.com/gobwas/ws"
"github.com/gobwas/ws/wsutil"
"github.com/steemit/jussi/internal/helpers"
)

// Client represents a WebSocket client connection
Expand Down Expand Up @@ -39,7 +40,9 @@ func NewClient(url string) (*Client, error) {

// Send sends a message
func (c *Client) Send(ctx context.Context, payload map[string]interface{}) error {
data, err := json.Marshal(payload)
// Marshal with HTML escaping disabled to keep literal < > & chars.
// steemd's FC JSON parser does not understand \uXXXX escapes.
data, err := helpers.MarshalJSONWithoutHTMLEscape(payload)
if err != nil {
return fmt.Errorf("failed to marshal: %w", err)
}
Expand Down
Loading