Homework 4 - WebSockets




Introduction


In this assignment you will add real-time features using WebSockets. By the end, users will be able to see who is online, send each other direct messages that arrive instantly, and video call each other.

As with previous homeworks, some objectives may require that you edit the provided front end, or build your own if you'd prefer.

All objectives must function properly using docker compose. When running "docker compose up" your app must be fully functional on localhost port 8080.

Security Concerns


Every security concern is clearly labeled. If you violate a security concern, all 5 objectives will be scored 0 unless/until you submit a proper security essay for each security risk in your submission.


Deviations


In addition to modifying the front end, or building your own, you are free to deviate from the specific details of each objective as long as you still implement the features for each objective. This offers you flexibility to implement your app the way you'd like, while still learning the concepts of each objective. There is no limit to the amount you are allowed to deviate as long as the concepts of the objective are still covered.

If you choose to deviate from a stated objective, you must justify how this deviation still covers the concepts required for the objective. Deviations never make a security concern acceptable. Any deviation that avoids handling a security concern does not cover the concepts of the objective and is not allowed (e.g. Accepting WebSocket handshakes from any origin). Likewise, any deviation that uses a library to do the work an objective is teaching does not cover the concepts of the objective (e.g. Using a library to complete the WebSocket handshake or to parse WebSocket frames).


Objective 1: WebSocket Echo


Goals:

  • Upgrade HTTP connections to WebSocket connections
  • Parse and build WebSocket frames
  • Echo messages back to the user who sent them

Concepts Covered:

  • The WebSocket handshake
  • WebSocket frames and bit-level parsing
  • Masking

Pages

Add the following path, rendered using layout.html:

  • "/test-websocket" - Render test-websocket.html

The Handshake

The front end opens a WebSocket connection by sending a GET request to the path "/websocket". When you receive a request at this path, complete the WebSocket handshake by responding with a 101 Switching Protocols response containing the correct Sec-WebSocket-Accept header.

After you send this response, the connection is a WebSocket connection. Every byte that follows, in both directions, is part of a WebSocket frame.

  • You may use libraries to compute the SHA-1 hash and the Base64 encoding (e.g. crypto/sha1 and encoding/base64)

Frames

You will need to both parse and create WesSocket frames in this objective. It is recommended that you write the following functions and thoroughly test them before connecting them to your server:

  • ParseFrame(data []byte) Frame - Takes the bytes of one entire WS frame and returns a Frame struct with fields for the FIN bit, opcode, payload length, and the unmasked payload
  • GenerateFrame(payload []byte) []byte - Returns a WS frame containing the payload

Echo

When you visit "/test-websocket" on the front end and enter a message, it will be sent to your server in a WS frame. Parse this frame, extract the payload, and send the payload back to the sender in a new WS frame.

Every WebSocket message sent by the front end is a JSON object with a field named "messageType" that determines how your server should handle the message. Every message your server sends will also be a JSON object with a "messageType" field.

Whenever you receive a message of type "echo_client", read the "text" field and send a message of type "echo_server" back to only the sender, with a "text" field containing exactly the text that was sent.

Received: {"messageType":"echo_client","text":"WebSockets are for winners"}

Sent: {"messageType":"echo_server","text":"WebSockets are for winners"}

  • We will only test this objective with messages that are less than 1500 bytes (i.e. no buffering needed if you read 2048 bytes from the socket). Larger messages will be sent in objective 2
  • When you receive a close frame (opcode 1000), close the TCP connection and end the goroutine for that connection
  • You may assume the RSV bits are always 0. You do not have to support binary frames

Tips:

  • If the browser closes the connection immediately after you send a frame, your frame is malformed. Check the console for the error
  • You may choose if guest users are allowed to use this feature or not, though it's recommended to only allow authenticated users to use any WS feature when you implement objective 3

Optional:

  • When you receive a close frame, send a close frame back before closing the TCP connection, as RFC 6455 requires

Objective 2: Continuation Frames and Back-to-Back Frames


Goals:

  • Receive messages that are split across multiple frames
  • Correctly parse frames no matter how they are split across reads from the connection

Concepts Covered:

  • TCP as a stream of bytes
  • Continuation frames and the FIN bit
  • Buffering
  • Delimiting frames by payload length

Buffering

WebSocket frames can be much larger than a single read from the connection. Use the payload length to determine the total size of each frame, and continue reading until you have received the entire frame. As with HW3, you must write this buffering yourself (e.g. io.ReadFull is not allowed), and you may not read an entire frame with a single, very large read.

Expand your WebSocket code to support the processing of multiple frames under the following two conditions:

  • Continuation Frames
    • Check the FIN bit and handle messages that are sent over multiple frames.
    • We will test with payloads > 131,000 bytes using Chrome, which sends messages over this size in multiple frames
  • Back-to-Back Frames
    • Your server must also handle multiple frames sent back-to-back. A single read from the connection may contain the end of one frame and the beginning of the next, several complete frames, or only part of a frame. Every frame must be parsed correctly no matter where the reads split

To complete this objective, your server must handle the case where multiple messages requiring continuation frames are sent back-to-back. This applies to every feature that uses your WebSocket connection, not only echo.

Tips:

  • To test back-to-back frames, edit the front end to send each echo message 5 times. Every message should be echoed 5 times
  • Keep a buffer of bytes you've read but haven't parsed yet. Parse complete frames from the front of this buffer, and only read from the connection when the buffer doesn't contain a complete frame
  • Only parse the JSON after reassembling the entire message. A single fragment may not be valid JSON, or even valid UTF-8

Optional:

  • Limit the total size of a message across all of its frames, and close any connection that exceeds it with status code 1009 (Message Too Big). The 64-bit payload length lets an attacker claim a frame of up to 16 exabytes, and continuation frames let them send a message that never ends. Your maximum size must be greater than 500,000 so we can still test your continuation frames.

Objective 3: Active Users and Heartbeats


Goals:

  • Authenticate WebSocket connections
  • Protect your users from Cross-Site WebSocket Hijacking
  • Show every user who is currently online
  • Detect and remove connections that died without sending a close frame

Concepts Covered:

  • Authenticating WebSocket connections
  • Cross-Site WebSocket Hijacking and the Origin header
  • Broadcasting over WebSockets
  • Ping/pong control frames
  • Shared state between goroutines

Authenticating Connections

WebSocket frames do not have headers (i.e. no cookies or auth tokens). The handshake is the only HTTP request sent on a WebSocket connection, which makes it your only chance to read the user's auth_token cookie. Authenticate the user during the handshake, the same way you authenticate any other request, and store their username with the connection.

  • You may disallow guest users from opening WS connections at this point and respond to the handshake with a 401. If you allow them, they must only be able to interact with the echo feature and other WS frames should be rejected
  • Never identify a user by a username, or any other value, sent in a WebSocket message. The only identity you can trust is the one you authenticated during the handshake

Active Users List

To let users know who is available on the app, send a message of type "active_users_list" containing a field named "users" with an array of every authenticated user who has an open WebSocket connection. Each user is represented as an object with a field "username".

Example Message: {"messageType": "active_users_list", "users": [{"username": "admin"}, {"username": "cse312"}]}

  • Send the list to every new connection when its handshake completes successfully
  • Broadcast the updated list to every open WebSocket connection whenever the list changes
  • A connection is closed when you receive a close frame from it, or when it fails a heartbeat (below)

Heartbeats

When a browser loses its network connection (e.g. a laptop goes to sleep or loses its Wi-Fi), it does not send a close frame. Your server can't tell the difference between a quiet connection and a dead one, and that user would appear online forever. To detect these connections, you will send ping frames, which browsers automatically answer with pong frames.

  • Send a ping frame to every open WebSocket connection every 5 seconds. Browsers automatically respond to each ping with a pong frame (You do not need to change the front end)
  • If a connection has not sent a pong since its previous ping by the time its next ping is due, close the connection and remove it, including updating the active users list
  • A pong may arrive in between the frames of a message that was split into continuation frames. Your server must handle control frames (ping, pong, and close) that arrive between continuation frames without corrupting the message being reassembled
  • We will test with a client that stops responding to pings. That user must disappear from every other user's active users list within 10 seconds

Checking the Origin

The Same-Origin Policy does not apply to WebSockets. Any page on any site can open a WebSocket connection to your server, the browser will attach your user's cookies to the handshake, and that page can read every message your server sends on the connection. However, browsers send an Origin header with every handshake, and JavaScript cannot change it. Your server can use this header to reject connections opened by other sites.

  • Before completing a handshake, check its Origin header. If the Origin is missing, or is not one of your app's origins, respond with a 403 Forbidden and do not upgrade the connection
  • The check must be an exact match of the entire origin (scheme, host, and port). "http://localhost:8080" must be allowed so we can test your app
Security Concern - Cross-Site WebSocket Hijacking: Your server must never complete a WebSocket handshake whose Origin header does not match your app's origins.

Concurrency

Your server now has many goroutines sharing data (e.g. your collection of open connections) and writing to the same connections: each connection's own goroutine, other connections' goroutines broadcasting the active users list, and your heartbeats. During grading, we will open many connections at the same time, send messages from all of them at once, and close them all at once. Your server must keep running, every frame it sends must be valid, and a connection opened afterwards must receive an accurate active users list.

Tips:

  • Run your server with "go run -race ." while testing. The race detector reports data races as they happen, even when nothing crashes (This does not catch all concurrency issues however)
  • Go maps are not safe for concurrent use. Concurrent access to a map can crash your entire server with "fatal error: concurrent map writes". Protect your shared data with a sync.Mutex
  • We're not using the drawing board this semester, but the page contains a user list. Rendering this page can make it easier to test this objective

Optional:

  • Add tickets for extra XSRF protection. Before connecting, the front end requests a random, single-use ticket with a short expiration (e.g. 30 seconds) from an XSRF-protected endpoint, then includes it in the WebSocket URL (e.g. "/websocket?ticket=..."). Your server verifies and deletes the ticket during the handshake. This requires front end changes
  • Close every WebSocket connection belonging to a user when they log out. Otherwise, a connection keeps the identity it had during its handshake for as long as it's open
  • Handle multiple sessions. If a user has multiple tabs/browsers open on your app, they should only be listed one time. They should only be removed from the list when they close their last session

Objective 4: Direct Messages


Goals:

  • Allow logged in users to send private messages to each other that arrive instantly
  • Store and retrieve the history of each conversation

Concepts Covered:

  • Sending WS messages to specific users
  • Authorization of WebSocket messages
  • Concurrent writes to a connection

Pages

Add the following path, rendered using layout.html:

  • "/direct-messaging" - Render direct-messaging.html

Implement DMs on the Direct Messaging page. Only authenticated users can use this feature. Ignore these message types when they are sent by guests.


All Users

When a client sends a message of type "get_all_users", send them a list of every user who has registered on your app, whether or not they are currently online. This list populates the dropdown of users to DM.

  • Received: {"messageType":"get_all_users"}
  • Sent: {"messageType": "all_users_list", "users": [{"username": "admin"}, {"username": "cse312"}]}

Message History

When a client sends a message of type "select_user", which means they chose this user from the DM dropdown, send them every DM between them and the chosen user in a message of type "message_history", sorted by age (oldest first).

  • Received: {"messageType":"select_user","targetUser":"koby"}
  • Sent: {"messageType": "message_history", "messages": [{"messageType": "direct_message", "fromUser": "admin", "text": "hi"}, {"messageType": "direct_message", "fromUser": "koby", "text": "hey"}]}

Sending DMs

  • Received: {"messageType":"direct_message", "targetUser":"koby", "text":"hi"}
  • When you receive a DM, store it in your database, then send it to both users in the conversation
  • Sent: {"messageType": "direct_message", "fromUser": "admin", "text": "hi"}
  • Send each DM to every open connection of both users. A DM sent to a user who is offline is still stored, and appears in their message history
  • If the target user does not exist, ignore the message

If many users send DMs to the same user at the same time, every DM should arrive intact.

Security Concern - DM Privacy: A DM must only ever be sent to the two users in the conversation, and message history must only be sent to a user who is part of that conversation. The sender of a DM is always the user authenticated on the connection, never a value sent in a message.
Security Concern - Escape HTML: DM text is user-provided content. You must escape any HTML in DMs before sending them to other users.

Tips:

  • Your heartbeats, broadcasts, and DMs from many users can all write to the same connection at the same time. Revisit the concurrency tips in Objective 3
  • Store each DM with a timestamp, or anything else you can sort by, so you can send the history in order

Optional:

  • Notify users when they receive a DM while viewing a different conversation. This requires front end changes

Objective 5: Video Calls with WebRTC


Goals:

  • Allow users to create video calls that other users can join
  • Act as the signaling server that connects your users' browsers to each other

Concepts Covered:

  • WebRTC
  • Signaling servers
  • Peer-to-peer connections
  • NATs, STUN, and ICE candidates

Pages

Add the following paths, rendered using layout.html:

  • "/video-call" - Render video-call.html (Lists all calls and creates new calls)
  • "/video-call/{roomID}" - Render video-call-room.html (A single call. Host the same HTML for any roomID)

Build a Zoom clone. This feature will allow users to create video call "rooms" that can be joined by other users. Each room can contain any subset of users, and they can all share audio and video in peer-to-peer connections using WebRTC. The audio and video never pass through your server. Your server's job is to pass the messages that let your users' browsers find and connect to each other. Only authenticated users can use this feature.


Creating Calls (`POST /api/video-calls`)

The body of the request will be a JSON object with a field named "name" that should be stored as the name of the room. Your app will respond with the id of the room (Generated by you) in a JSON object (e.g. {"id":"67e42b3b0cb5bda054049243"}). Requests from users who are not logged in must be rejected with a 401 Unauthorized.


Signaling Messages

To enable this feature, you'll need to support the following WebSocket messages:

  • messageType - "get_calls"
    • Sent by the client. This is a request for a list of all the calls so the Video Call page can populate all the available calls
  • messageType - "call_list"
    • Sent by your server in response to a get_calls message. This is a list of every available call in a JSON object with the fields "id" and "name"
    • Example message - {"messageType": "call_list", "calls": [{"id": "67e426f9042e96b4cfaa70f2", "name": "meeting"}, ... ]}
  • messageType - "join_call"
    • Sent by the client when they join a video call. Contains the id of the room being joined
    • Example message - {"messageType":"join_call", "callId":"67e426f9042e96b4cfaa70f2"}
  • messageType - "call_info"
    • Sent by your server when a user joins a call. Contains the name of the room
    • Example message - {"messageType":"call_info", "name":"meeting"}
  • messageType - "existing_participants"
    • Sent by your server to let the users of a room know who else is on the call. Send this to a user when they join the room in reaction to the "join_call" message. Their front end will then send messages to start all the required WebRTC connections
    • Example message - {"messageType": "existing_participants", "participants": [{"socketId": "febd4d3a-9e61-4a95-8e52-37ba64e2674c", "username": "admin"}, ... ]}
    • Generate a unique id for each WebSocket connection when its handshake completes (e.g. a random UUID). This id identifies a connection, not a user, since a user could have multiple connections
  • messageType - "user_joined"
    • Sent by your server when a user joins the room. Broadcast this to all users in the room each time a user joins
    • Example message - {"messageType": "user_joined", "socketId": "0a8e1a10-7f20-4797-bd3e-628735f91216", "username": "jesse"}
  • messageType - "offer", "answer", and "ice_candidate"
    • Sent by the client. Each of these message types contain the corresponding WebRTC information to establish a connection. Each of these messages will contain a "socketId" field. You should forward the message to the connection matching this socket id with a "socketId" field populated with the sender's socket id and a "username" field containing the sender's username (The recipient doesn't need to know their own socket id/username, but they do need to know the sender)
    • Only forward these messages if the recipient is in the same call as the sender. Otherwise, any user could send WebRTC messages to any connection on your server
    • You do not need to parse the WebRTC information inside these messages. Forward it as-is
  • messageType - "user_left"
    • Sent by your server to all users in a call when one of them disconnects their WebSocket, for any reason (including a failed heartbeat). Must contain their "socketId" in a field

Tips:

  • A call needs at least two users. Test with two different browsers, or a normal and an incognito window, logged in as different users
  • Browsers only allow WebRTC on secure origins. localhost is treated as secure, so testing on your machine works. Testing between devices requires HTTPS, which we'll discuss for the project

Optional - Your Own STUN Server:

The provided front end uses Google's STUN server. Replace it with your own:

  • Write a UDP server (e.g. using net.ListenPacket("udp", ":3478")) that responds to each STUN Binding Request with a Binding Success Response containing an XOR-MAPPED-ADDRESS attribute in accordance with RFC 8489
  • Run it with docker compose, and publish its port for UDP (e.g. "3478:3478/udp" in your compose.yml)
  • Change the iceServers in the front end to use your server (e.g. "stun:localhost:3478")
  • Sometimes the STUN server is not used when a local connection can be established. If no requests arrive, try your computer's local network IP address in place of localhost

Submission


Submit all files for your server to Autolab in a .zip file

Complete the homework submission form after submitting your zip file to Autolab: Homework Submission Form

If you do not both submit your zip file to Autolab and complete the homework submission form before the due date, your submission will not be graded. Do not be one of the students who will get a 0 for not submitting the form after completing the assignment.

Your submission must include a ".env" file with placeholder values for all your sensitive values. This is required so we know your variable names. You can optionally include only a ".env.example" file with all your variables as is standard practice. In any case, we need a simple way to find the names of all your environment variables while grading.

To reduce hard drive usage, Autolab will limit the size of submissions. Do not include any [large] uploaded images, uploaded videos, or HLS files in your submission. You still must include everything needed to run your app, including the entire front end.

It is strongly recommended that you download and test your submission after submitting. To do this, download your zip file into a new directory, unzip your zip file, enter the directory where the files were unzipped, run "docker compose up --build --force-recreate --renew-anon-volumes", then navigate to localhost:8080 in your browser and test all your features. This simulates exactly what the TAs will do during grading.

If you ignore this recommendation, don't be surprised when you do not earn credit even if your code functions properly on your laptop.


Grading


Each objective will be scored on a 0-3 scale as follows:

3 (Complete) Clearly correct
2 (Complete) Mostly correct, but with some minor issues
1 (Incomplete) Not all features outlined in this document are functional, but an attempt was made to complete the objective
0 (Incomplete) No attempt to complete the objective or violation of the assignment (Ex. Using an HTTP library, or a WebSocket library) -or- The HW submission form was never submitted
0.X (Security Risk) If any security risk is found while testing, all objectives will be scored 0 unless a proper security essay is submitted. X is the score that will be earned if a proper security essay is submitted on time

Note that for your final grade there is no difference between a 2 and 3, or a 0 and a 1.

3 Objective Complete
2 Objective Complete
1 Objective Not Complete
0 Objective Not Complete

A Security Risk is any violation of a "Security Concern" that is explicitly labeled in this document.



Security Essay


If a security risk is found in your submission, you will be assigned a 0 for all 5 objectives. However, you will still have an opportunity to earn credit for the objectives by submitting an essay about the security issue you exposed. These essays must:

  • Be at least 1000 words in length
  • Explain the security issue from your submission with specific details about your code
  • Describe how you fixed the issue in your submission with specific details about the code you changed
  • Explain why this security issue is a concern and the damage that could be done if you exposed this issue in production code with live users

If your submission contains multiple security risks, you may have to write multiple essays to recover your homework grade

Any submission that does not meet all these criteria will be rejected and your objective will remain incomplete.

Due Date: Security essays are due 1-week after grades are released. Submission instructions will be included in your feedback on Autolab.

Any essay may be subject to an interview with the course staff to verify that you understand the importance of the security issue that you exposed. If an interview is required, you will be contacted by the course staff for scheduling. Decisions of whether or not an interview is required will be made at the discretion of the course staff.