May 30, 2022
We designed Haven for private sharing, and we built it on top of a combination of old, boring protocols: RSS feeds, and HTTP Basic Auth. These are both very standard protocols, but the combination isn’t as popular. HTTP Basic Auth gets treated like a niche or premium feature by some of the feed readers that support it. This means that there hasn’t been a well-discussed standard for including images in a private RSS feed.
When a feed reader fetches a private feed using HTTP basic auth, that feed may include images. Those images can be publicly accessible–in which case there are no issues–or the images can be private. For private images, we need an additional authentication scheme. We could propose that the feed reader fetch all images using the same HTTP basic auth credentials, but there are many feed readers and none of them do this. It also increases the complexity of a feed reader. Instead of fetching the feed and displaying it–allowing the browser to handle the details–it requires active parsing of the feed content, proxying requests for images and rewriting the feed before displaying it.
If a user is logged into the site where the feed comes from, then they have a session cookie which their browser will use to fetch the image. But the point of using feeds is to avoid needing to login to many different sites.
After a lot of deliberation, we decided that Haven’s private RSS feeds must expose images natively, without any additional features required in the feed reader. We needed another way to send credentials.
Haven sends image credentials as query parameters in private feeds. These are not universal credentials. We should expect people to share links to images. If those links contain universal credentials then sharing an image leaks full access to a private source. Instead we create a unique credential per-user and per-image. Haven uses an HMAC, along with a user identifier, and saves a per-user key in the database. The HMAC is built from the key and the image filename. The server is then able to reconstruct the HMAC when a request comes in for an image to validate the credential.
Let’s take a look at the code!
First, let’s visit the User table.
...
t.string "basic_auth_username", null: false
t.string "basic_auth_password", null: false
t.string "image_password", null: false
...
Each user has an email and password-hash for logging, but each user also has three additional fields: basic_auth_username
, basic_auth_password
, and image_password
.
All three of these fields are generated by Haven, and are random strings. The basic_auth_username
and basic_auth_password
serve as per-user credentials for RSS feeds. Haven indexes the basic_auth_username
for efficient lookup of a user in the database. image_password
is a bit of a misnomer since it isn’t really a password. Haven uses the image_password
as a secret key for generating image credentials.
Next we can take a look at the code which generates the RSS feed itself. Specifically, there is a global substitution which takes a regular expression and passes each match to a block.
image_key = @user.image_password
basic_auth_user = @user.basic_auth_username
## embed HMAC image credentials as query parameters
rss_content.gsub!(/\/images\/(\w*)\/(\d*)\/([^"]*)"/) do |m|
file = $3
hmac = OpenSSL::HMAC.hexdigest("SHA256", image_key, file)
"/images/#{$1}/#{$2}/#{$3}?u=#{basic_auth_user}&c=#{hmac}\""
end
The regular expression uses capture groups for each part of an image path. By referencing the right capture group, we can extract the filename and compute an HMAC from the filename and the image_password
. In the block we then replace the image path with an equivalent path that adds the newly-created HMAC and the basic_auth_username as query parameters.
Lastly, we can explore the validation code for serving images.
basic_auth_user = params[:u]
credential = params[:c]
if basic_auth_user.nil? || credential.nil?
authenticate_user! #check for a session cookie
else
filename = params[:filename] + "." + params[:format]
user = User.find_by(basic_auth_username: basic_auth_user)
image_key = user.image_password
hmac = OpenSSL::HMAC.hexdigest("SHA256", image_key, filename)
unless hmac == credential
raise ActiveRecord::RecordNotFound
end
end
Here we extract the included basic_auth_username
and HMAC-based credential from the query parameters. We extract the filename
from the path parameters. We use the basic_auth_username
to lookup the image_password
for that user from the database. With the filename
and image_password
, we can recompute the HMAC and compare it to the credential in the query.
With this logic, we get to save a single per-user key in the database (image_password
), and still create a unique per-image credential to securely allow feed readers to show images in Haven’s private feeds.
This works great for Haven’s private feeds and should be a viable best-practice for anyone else publishing private RSS feeds.