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.