serveBlob: only serve blobs referenced by feeds we replicate

serveBlob handed any held blob to anyone who knew its hash, on a public
port with require_opt_in:false. Combined with ssb-blobs sympathy that
meant publicly serving 4.6GB of strangers content we never reviewed.

Maintains a set of blob ids referenced by the local log (small, ~360KB,
rescanned every 5 min) and 404s anything outside it. Fails open until the
first scan completes so a read error cannot 404 the whole site.

Verified: a blob present on disk but referenced by no message returns
blobs.has=true over RPC while the viewer 404s it.
This commit is contained in:
ops
2026-08-14 20:13:02 +00:00
parent a39af4fad3
commit f7f1c30eae
2 changed files with 206 additions and 0 deletions
+43
View File
@@ -44,6 +44,43 @@ exports.version = require('./package').version
exports.init = function (sbot, config) {
var conf = config.viewer || {}
// --- known-blob gate -----------------------------------------------------
// This node used to cache blobs on behalf of strangers (ssb-blobs `sympathy`
// defaults to 3), and serveBlob would hand any of them to anyone who knew the
// hash. sympathy is 0 now and the cache has been pruned, so everything we hold
// is referenced by a feed we replicate. This keeps that true even if a stray
// blob ever lands.
//
// The local log is small (~360KB), so a regex scan is cheap. It is refreshed
// periodically rather than kept live: being a few minutes stale can only delay
// a legitimate image, never serve one that is not ours.
var knownBlobs = null // null = not loaded yet -> fail open, never 404 everything
var logOffsetPath = path.join(config.path, 'flume', 'log.offset')
function refreshKnownBlobs() {
fs.readFile(logOffsetPath, function (err, buf) {
if (err) {
console.error('[viewer] blob gate: could not read log.offset:', err.message)
return // keep whatever set we already had
}
var found = buf.toString('binary').match(/&[A-Za-z0-9+/]{43}=\.sha256/g) || []
var next = Object.create(null)
found.forEach(function (id) { next[id] = true })
if (knownBlobs === null) {
console.log('[viewer] blob gate active:', Object.keys(next).length, 'known blobs')
}
knownBlobs = next
})
}
refreshKnownBlobs()
setInterval(refreshKnownBlobs, 5 * 60 * 1000).unref()
// serveBlob is a free function outside this closure, so hand the check over.
sbot.isKnownBlob = function (id) {
if (knownBlobs === null) return true
return !!knownBlobs[id]
}
var port = conf.port || 8807
var host = conf.host || config.host || '::'
@@ -474,6 +511,12 @@ function serveBlob(req, res, sbot, id, query) {
var etag = id + (unbox || '')
if (req.headers['if-none-match'] === etag) return respond(res, 304)
// Only serve blobs referenced by a feed we replicate. Anything else is not
// ours to hand out, so treat it as absent rather than confirm we hold it.
if (typeof sbot.isKnownBlob === 'function' && !sbot.isKnownBlob(id)) {
return respond(res, 404, 'Not found')
}
sbot.blobs.has(id, function (err, has) {
if (err) {
if (/^invalid/.test(err.message)) return respond(res, 400, err.message)