Forwarding the Raw Body & x-line-signature
Overview
This is a commonly misunderstood point: webhook-go does not verify the x-line-signature
header itself.
A review of both internal/ and cmd/ turns up no HMAC-SHA256 comparison against a channel
secret anywhere in this project. What does exist is internal/util/hash.go (HMACShort, a
general-purpose short hash helper) and internal/util/base64.go (fileSignature, which reads
a file's magic bytes) — neither is related to LINE signature verification.
What the service does instead is preserve the raw request body bytes untouched, so that downstream consumers — whether a customer endpoint receiving a forward, or worker-go consuming the queue — can verify the signature themselves. This is a transfer of responsibility, not an oversight.
The technical reason: x-line-signature is an HMAC-SHA256 over the entire raw body. If the
payload is passed through json.Unmarshal and then json.Marshal, Go sorts map keys
alphabetically. The bytes change and the signature breaks immediately, even though the JSON
content is semantically identical.
Business Flow
processLinereads the body withio.ReadAll(c.Request.Body), producingraw []byte.- The bytes are copied into
BodyRaw, typed asjson.RawMessage. This is a real copy, not an alias to a buffer that gin will later reuse. rawis parsed into amap[string]interface{}stored in theBodyfield, used only for internal routing logic (readingevents[].type,source.userId,message.text,postback.data). It is never serialised back out.- When publishing to a queue,
LineWebhookPayload.MarshalJSONis overridden to emitBodyRawverbatim as thebodyfield. It only falls back to marshalling theBodymap whenBodyRawis empty, i.e. when the body was not a JSON object. - When forwarding over HTTP,
BodyRawis sent as the request body and thex-line-signatureheader is copied along with it, so the receiving system can match the signature against exactly the bytes it received. - All header keys are lowercased at the handler layer, because Go canonicalises them to
X-Line-Signaturewhile downstream systems ported from Express look them up asx-line-signature.
Security implications worth recording
POST /api/line/:idis public and unauthenticated. Anyone who knows awebhookIdcan post a forged payload. The only real defences are per-IP rate limiting and the fact thatwebhookIdis hard to guess.- Real verification has to happen in worker-go, the consumer of
line_webhook. If the worker does not verify, then effectively nothing in the pipeline does. - The queued payload carries the complete
headersmap including the signature, so downstream consumers have everything they need to verify.
Key Files & Functions
| File | Relevant part |
|---|---|
internal/line/handler.go | Handler.processLine performs io.ReadAll and copies into bodyRaw; toLowerHeader |
internal/line/service.go | LineWebhookPayload.MarshalJSON, webhookPayload.MarshalJSON, and marshalWebhookBody, the shared helper that prefers raw over the map |
internal/line/service.go | Service.forwardWebhook sends the raw bytes and re-sets the signature header on the outgoing request |
internal/line/rawbody_test.go | Tests asserting the emitted body is byte-for-byte identical to the original |
Connections to Other Services
- worker-go — responsible for actual verification, since it receives both the
headersmap and the raw body. - Customer endpoints (
forwardWebhookUrl) — can verify independently because they receive both the raw body and the signature header (see Forwarding Webhooks to Customer Systems). - Maintenance warning: never mutate the
Bodymap and re-marshal it on the publish or forward path — doing so breaks this parity immediately. The code carries warning comments at all three locations. - Known parity gap: in
mbox_handoff, theexitandmbox_teambranches send awebhookPayloadwithout settingBodyRaw, so the outgoing body is re-marshalled from the map with reordered keys. This differs from thehandoffanddepartment_pickerbranches, which send the raw bytes (see Handoff to Human Agents).