feat(webapp): refuse to share a file that looks like it holds credentials (BEA-111) (#137)

Minting a share link ran zero content checks: a file holding an AWS-shaped
key became a public URL on one click, and the CLI printed nothing but the
link. handleShareCreate now reads the first 1 MiB and runs six anchored
rules between the synced-path check and Shares.Create, answering 409 with
rule ids and line numbers unless the request carries confirm: true.

The matched text never leaves scanSecrets — not into the body, not into a
log line. TestShareSecretNeverEchoed greps both for the planted string,
because a 409 body is the easiest place in this codebase to leak it.

Both callers carry the override, since the gate alone would turn any false
positive into a hard block with no way out: `bdrive share --force`, and the
browser's Share-anyway dialog on modalConfirm (no new component). A path
that already has a live link skips the scan — its content is public
already, so withholding the URL protects nobody — but alreadyPublic drops
links whose creator left the org, since those 404 at /s/ and would
otherwise wave a secrets file straight through.

A failed blob read is 503, not a silent pass: the repo's "degrade rather
than fail" posture is for sync cycles, and a check that skips itself on a
storage hiccup is the false confidence this exists to remove.

Every user-facing string says the file was checked at the moment you shared
it. A link serves the file's LATEST content forever, so a key written into
an already-shared file is never caught — that open loop stays open, and the
copy is the only thing stopping v1 from claiming otherwise.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee (Sungwon)
2026-08-11 04:33:05 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent 594a027c15
commit d3d92bf904
18 changed files with 701 additions and 133 deletions
+69
View File
@@ -496,6 +496,10 @@ func startTestHub(t *testing.T) *httptest.Server {
}
srv := &Server{Root: be, Projects: db, Device: webDevice, Upload: UploadConfig{Enabled: true}}
srv.Devices, _ = OpenDeviceRegistry(filepath.Join(state, "devices.json"))
srv.Shares, err = OpenShareDB(filepath.Join(state, "shares.json"))
if err != nil {
t.Fatal(err)
}
auth, err := OpenBuiltinAuth(filepath.Join(state, "auth.json"), false, nil)
if err != nil {
t.Fatal(err)
@@ -713,3 +717,68 @@ func TestCLITemplateRefusals(t *testing.T) {
t.Fatal("a refused init still initialized the folder")
}
}
// `bdrive share` refuses a file that looks like it holds credentials, and
// --force is the way past it. This is the flow BEA-111 exists for: the CLI
// used to print the URL and nothing else.
func TestCLIShareSecretGate(t *testing.T) {
e := newCLIEnv(t)
run := e.run
work := t.TempDir()
// Fabricated, AWS-shaped. Not a credential.
const plantedKey = "AKIAIOSFODNN7EXAMPLE"
if err := os.WriteFile(filepath.Join(work, "deploy.md"), []byte(
"# Deploy\n\nexport AWS_ACCESS_KEY_ID="+plantedKey+"\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(work, "clean.md"), []byte("# Clean\n\nnothing here\n"), 0o644); err != nil {
t.Fatal(err)
}
if out, err := run(work, "init", "--name", "share-gate", "--yes"); err != nil {
t.Fatalf("init: %v\n%s", err, out)
}
defer run(work, "stop", work)
if out, err := run(work, "sync"); err != nil {
t.Fatalf("sync: %v\n%s", err, out)
}
// A clean file shares as it always did.
out, err := run(work, "share", "clean.md")
if err != nil || !strings.Contains(out, "/s/") {
t.Fatalf("clean file did not share: %v\n%s", err, out)
}
// The planted file is refused, by rule and line, and names the way out.
out, err = run(work, "share", "deploy.md")
if err == nil {
t.Fatalf("share of a file holding a key succeeded:\n%s", out)
}
for _, want := range []string{"aws_access_key_id", "line 3", "--force", "at the moment you shared it"} {
if !strings.Contains(out, want) {
t.Fatalf("share refusal missing %q:\n%s", want, out)
}
}
if strings.Contains(out, plantedKey) {
t.Fatalf("the CLI echoed the secret back:\n%s", out)
}
if strings.Contains(out, "/s/") {
t.Fatalf("a refused share still printed a URL:\n%s", out)
}
// --force is the override, and the link it mints works.
out, err = run(work, "share", "deploy.md", "--force")
if err != nil || !strings.Contains(out, "/s/") {
t.Fatalf("share --force: %v\n%s", err, out)
}
link := strings.TrimSpace(strings.SplitN(out, "\n", 2)[0])
resp, err := http.Get(link)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 || !strings.Contains(string(body), "Deploy") {
t.Fatalf("forced link does not serve: %d %s", resp.StatusCode, body)
}
}
+3
View File
@@ -239,6 +239,9 @@ func seedE2E(t *testing.T, state, prefix, projectID string) {
put("guide.md", "# Guide\n\nSecond version of the guide, with more detail.\n", 2*time.Hour)
put("notes/readme.md", "# Notes\n\nNested folder content.\n", 24*time.Hour)
put("notes/deep/topic.md", "# Topic\n\nDeeply nested file.\n", 24*time.Hour)
// The share gate needs something to fire on. Fabricated, AWS-shaped —
// not a credential, and the only seeded file that holds one.
put("deploy.md", "# Deploy\n\nexport AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE\n", 24*time.Hour)
// Tiny valid PNG (1x1), enough to exercise the binary/download path.
png := "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89" +
"\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
@@ -220,6 +220,49 @@ test("share mints a public link that serves the file, revoke kills it", async ({
expect(gone.status()).toBe(404);
});
// BEA-111: sharing a file that looks like it holds credentials asks first.
// Cancel mints nothing; Share anyway mints the link it would have.
test("share on a file holding a key asks before it mints, and Cancel mints nothing", async ({
page,
}) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/deploy.md`);
// Cancel: the dialog names the finding, and no link exists afterwards.
await page.click("#share-btn");
const dialog = page.locator(".modal", { hasText: "This file may contain credentials" });
await expect(dialog).toBeVisible();
await expect(dialog).toContainText("an AWS access key (line 3)");
// The copy may only ever claim what was true at mint time.
await expect(dialog).toContainText("at the moment you share it");
await expect(dialog).toContainText("later changes are never checked");
// …and it must never echo the thing it found.
await expect(dialog).not.toContainText("AKIA");
await dialog.locator("button:has-text('Cancel')").click();
await expect(page.locator(".modal-url")).toHaveCount(0);
const before = await (await page.request.get(`/api/p/${pid}/shares`)).json();
expect(before.shares.filter((s: { path: string }) => s.path === "deploy.md")).toHaveLength(0);
// Share anyway: the same click, carried through.
await page.click("#share-btn");
await page.locator(".modal button:has-text('Share anyway')").click();
const url = (await page.locator(".modal-url").textContent())!;
expect(url).toContain("/s/");
const publicRes = await page.request.get(url);
expect(publicRes.status()).toBe(200);
await page.click(".modal button:has-text('Done')");
// Already public: a second Share hands back the same link without asking.
await page.reload();
await page.click("#share-btn");
await expect(page.locator(".modal", { hasText: "This file may contain credentials" })).toHaveCount(0);
expect(await page.locator(".modal-url").textContent()).toBe(url);
await page.click(".modal button:has-text('Done')");
await page.request.delete(`/api/shares/${url.split("/s/")[1]}`);
});
// BEA-29: the CLI has had --expires all along; the dialog now offers it on
// the link you just minted, without changing that link's URL.
test("share dialog sets an expiry on the link it just minted", async ({ page }) => {
+41 -3
View File
@@ -32,6 +32,33 @@ import { Insights, useInsightsDevices } from "../components/Insights";
import { HistoryView, historyTitle } from "../components/HistoryView";
import { VersionBanner } from "../components/VersionBanner";
// The hub's six share-time credential rules, in words. Only one caller
// (shareNow), so it lives here rather than in its own file.
const SECRET_LABELS: Record<string, string> = {
aws_access_key_id: "an AWS access key",
openai_api_key: "an OpenAI API key",
github_pat: "a GitHub token",
slack_token: "a Slack token",
private_key: "a private key",
gitlab_pat: "a GitLab token",
};
// secretsMessage phrases the 409 for the confirm dialog. The second sentence
// is not decoration: a link always serves the file's LATEST content, so the
// copy may only ever claim what was true at the moment of sharing — never
// that the file is clean.
function secretsMessage(findings: { rule: string; line: number }[] = []): string {
const parts = findings.map((f) => `${SECRET_LABELS[f.rule] ?? f.rule} (line ${f.line})`);
const what =
parts.length > 1
? parts.slice(0, -1).join(", ") + " and " + parts[parts.length - 1]
: parts[0] || "something credential-shaped";
return (
`BearDrive found ${what} in this file. The check covers the file at the moment you share it — ` +
`a link always serves the file's latest content, so later changes are never checked. Share anyway?`
);
}
// The browsing surface shared by hub projects and single-volume mode: the
// file tree, folder listings, file views, and every topbar action. Sidebar
// chrome (vault header, project nav, org bar) is injected by the caller;
@@ -208,12 +235,23 @@ export default function Browser(props: {
const shareNow = useCallback(async () => {
// Shares are per-file; a selected folder has nothing to mint.
try {
const r = await fetch(apiBase + "shares", {
const post = (confirm: boolean) =>
fetch(apiBase + "shares", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
body: JSON.stringify(confirm ? { path, confirm: true } : { path }),
});
try {
let r = await post(false);
// 409: the hub found credential-shaped strings and minted nothing.
// Read the structured body — this is a raw fetch, so it never passes
// through errorFor() in api/http.ts, which would flatten it to a toast.
if (r.status === 409) {
const { findings } = (await r.json()) as { findings?: { rule: string; line: number }[] };
if (!(await modalConfirm("This file may contain credentials", secretsMessage(findings), "Share anyway", true)))
return; // Cancel mints nothing, and fires no share_created
r = await post(true);
}
if (!r.ok) throw new Error(await r.text());
const s = await r.json();
// Fired here rather than by the table in api/http.ts, because this is
+74
View File
@@ -0,0 +1,74 @@
package webapp
import (
"bytes"
"regexp"
"sort"
)
// Minting a share link is the one place on the hub where a member turns private
// bytes into a public URL, so it is the one place worth reading the bytes
// first. The check is deliberately narrow: six anchored rules over the first
// 1 MiB, at mint time only.
//
// It says nothing about the file tomorrow. A link serves the file's LATEST
// content forever (see the package comment in shares.go), so every string a
// user sees says the file was checked *at the moment you shared it* — never
// that the file is clean.
// secretScanLimit is how much of a file the share gate reads. The boundary is
// a decision, not an accident: a key past the first MiB mints silently, which
// is asserted in shares_test.go so nobody "fixes" it by accident.
const secretScanLimit = 1 << 20
// secretFinding is one credential-shaped string: which rule fired, and where.
// Never the matched text — see scanSecrets.
type secretFinding struct {
Rule string `json:"rule"`
Line int `json:"line"`
}
var secretRules = []struct {
id string
re *regexp.Regexp
}{
{"aws_access_key_id", regexp.MustCompile(`AKIA[0-9A-Z]{16}`)},
// The bodies below are what keep the prefixes off prose: a bare `sk-` in a
// sentence is not a key. If one still fires on real docs, tighten the body
// rather than dropping the rule — `--force` and Share anyway are the
// escape hatch, which is why they ship in the same change.
{"openai_api_key", regexp.MustCompile(`sk-[A-Za-z0-9_-]{20,}`)},
{"github_pat", regexp.MustCompile(`ghp_[A-Za-z0-9]{36}`)},
{"slack_token", regexp.MustCompile(`xox[baprs]-[A-Za-z0-9-]{10,}`)},
{"private_key", regexp.MustCompile(`-----BEGIN [A-Z ]*PRIVATE KEY-----`)},
{"gitlab_pat", regexp.MustCompile(`glpat-[A-Za-z0-9_-]{20,}`)},
}
// scanSecrets reports credential-shaped strings in buf, as rule ids and line
// numbers ONLY. The matched text must never reach a response body, a log line,
// or a metric label — the same argument reads.go:28-40 makes for actor
// identity, and a 409 body is the easiest place in the codebase to leak it.
//
// Byte-oriented on purpose: a bufio.Scanner over a 1 MiB minified file with no
// newline blows its 64 KiB token limit and returns nothing at all, which is a
// check that silently passes everything.
func scanSecrets(buf []byte) []secretFinding {
seen := map[secretFinding]bool{}
var out []secretFinding
for _, rule := range secretRules {
for _, m := range rule.re.FindAllIndex(buf, -1) {
f := secretFinding{Rule: rule.id, Line: bytes.Count(buf[:m[0]], []byte("\n")) + 1}
if !seen[f] {
seen[f] = true
out = append(out, f)
}
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Line != out[j].Line {
return out[i].Line < out[j].Line
}
return out[i].Rule < out[j].Rule
})
return out
}
+63
View File
@@ -0,0 +1,63 @@
package webapp
import (
"reflect"
"strings"
"testing"
)
// Fabricated, structurally-valid-looking strings. None is a real credential.
const (
fakeAWSKey = "AKIAIOSFODNN7EXAMPLE"
fakeOpenAIKey = "sk-abcdefghijklmnopqrstuvwxyz0123456789"
fakeGitHubPAT = "ghp_" + "abcdefghijklmnopqrstuvwxyz0123456789ab"
fakeSlackTok = "xoxb-1234567890-abcdefghij"
fakeGitLabPAT = "glpat-abcdefghij0123456789XY"
fakePrivKey = "-----BEGIN RSA PRIVATE KEY-----"
)
func TestScanSecrets(t *testing.T) {
for _, tc := range []struct {
name string
buf string
want []secretFinding
}{
{"clean", "# Notes\n\nnothing to see, sk- is just a prefix here\n", nil},
{"aws", "line1\nline2\nkey = " + fakeAWSKey + "\n", []secretFinding{{"aws_access_key_id", 3}}},
{"openai", "OPENAI=" + fakeOpenAIKey, []secretFinding{{"openai_api_key", 1}}},
{"github", "\n\n" + fakeGitHubPAT, []secretFinding{{"github_pat", 3}}},
{"slack", "token: " + fakeSlackTok, []secretFinding{{"slack_token", 1}}},
{"gitlab", "x\n" + fakeGitLabPAT, []secretFinding{{"gitlab_pat", 2}}},
{"private key", "a\nb\nc\n" + fakePrivKey + "\nMIIE...\n", []secretFinding{{"private_key", 4}}},
{
// One line, one rule, three keys: one finding, not three.
"multi key line deduped",
"a=" + fakeAWSKey + " b=AKIAZZZZZZZZZZZZZZZZ c=AKIAYYYYYYYYYYYYYYYY",
[]secretFinding{{"aws_access_key_id", 1}},
},
{
"two rules same line",
"env: " + fakeAWSKey + " " + fakeSlackTok,
[]secretFinding{{"aws_access_key_id", 1}, {"slack_token", 1}},
},
{
// A bufio.Scanner would blow its 64 KiB token limit here and report
// nothing at all — which is why scanSecrets is byte-oriented.
"no newline in a big buffer",
strings.Repeat("x", 300_000) + fakeAWSKey,
[]secretFinding{{"aws_access_key_id", 1}},
},
{
"sorted by line then rule",
fakeSlackTok + "\n" + fakeAWSKey,
[]secretFinding{{"slack_token", 1}, {"aws_access_key_id", 2}},
},
} {
t.Run(tc.name, func(t *testing.T) {
got := scanSecrets([]byte(tc.buf))
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("scanSecrets = %v, want %v", got, tc.want)
}
})
}
}
+7
View File
@@ -1392,6 +1392,13 @@ func storageErr(w http.ResponseWriter, code int, msg string, err error) {
}
func writeJSON(w http.ResponseWriter, v any) {
writeJSONStatus(w, http.StatusOK, v)
}
// writeJSONStatus is writeJSON for the answers a client has to read the body
// of — a 409 whose findings the CLI and the browser both decode.
func writeJSONStatus(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
+50
View File
@@ -249,6 +249,7 @@ func (s *Server) handleShareCreate(v *volume, w http.ResponseWriter, r *http.Req
var req struct {
Path string `json:"path"`
ExpiresIn string `json:"expires_in,omitempty"` // Go duration, e.g. "168h"
Confirm bool `json:"confirm,omitempty"` // share it anyway, secrets and all
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
@@ -275,6 +276,33 @@ func (s *Server) handleShareCreate(v *volume, w http.ResponseWriter, r *http.Req
return
}
}
if !req.Confirm && !s.alreadyPublic(r.PathValue("project"), p) {
rc, err := v.source.Open(r.Context(), p, snap.files[p])
if err != nil {
// Fails CLOSED. The repo's "degrade rather than fail" posture is for
// sync cycles; minting is a rare interactive action, and a check
// that skips itself on a storage hiccup is exactly the false
// confidence this gate exists to remove.
storageErr(w, http.StatusServiceUnavailable, "could not read the file to check it for credentials", err)
return
}
// 1 MiB and close: source.Open streams from the object store, so this
// aborts the rest of the transfer rather than pulling a 500 MB file
// down to look at its first megabyte. Don't "fix" it into a ReadAll.
buf, err := io.ReadAll(io.LimitReader(rc, secretScanLimit))
rc.Close()
if err != nil {
storageErr(w, http.StatusServiceUnavailable, "could not read the file to check it for credentials", err)
return
}
if findings := scanSecrets(buf); len(findings) > 0 {
writeJSONStatus(w, http.StatusConflict, map[string]any{
"error": "this file looks like it contains credentials",
"findings": findings,
})
return
}
}
sh, err := s.Shares.Create(r.PathValue("project"), p, s.requestUser(r).Email, ttl)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -284,6 +312,28 @@ func (s *Server) handleShareCreate(v *volume, w http.ResponseWriter, r *http.Req
writeJSON(w, shareJSON(r, sh, nil))
}
// alreadyPublic reports whether this path is already served to anyone with a
// URL. If it is, minting skips the credential scan: the content is public
// already, so withholding the link protects nothing and would break the
// "clicking Share again gives me the same link" behaviour the dialog is built
// on.
//
// It cannot key off ShareDB.Create's reuse branch, which is narrower (that one
// also requires no expiry on either side), and it has to drop links whose
// creator left the org — those 404 at /s/ (shareCreatorStillBelongs), so a
// secrets file whose only link is already dead must not wave through.
func (s *Server) alreadyPublic(project, p string) bool {
if s.Shares == nil {
return false
}
for _, sh := range s.Shares.List(project) { // List already drops expired
if sh.Path == p && s.shareCreatorStillBelongs(sh) {
return true
}
}
return false
}
func (s *Server) handleShareList(v *volume, w http.ResponseWriter, r *http.Request) {
if s.Shares == nil {
http.Error(w, "sharing is not enabled on this server", http.StatusNotFound)
+134
View File
@@ -2,10 +2,14 @@ package webapp
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
@@ -677,6 +681,136 @@ func authAs(t *testing.T, srv *Server, req *http.Request) {
req.Header.Set("Authorization", "Bearer "+tok)
}
// planted is a fabricated AWS-shaped string. No real credential is involved,
// here or in the fixtures below.
const planted = "AKIAIOSFODNN7EXAMPLE"
// postShare mints as a signed-in member and hands back the raw recorder, so a
// test can look at a non-200.
func postShare(t *testing.T, srv *Server, h http.Handler, project string, body any) *httptest.ResponseRecorder {
t.Helper()
req := jsonReq(t, "POST", "/api/p/"+project+"/shares", body)
authAs(t, srv, req)
return doHTTP(h, req)
}
func decodeFindings(t *testing.T, rec *httptest.ResponseRecorder) []secretFinding {
t.Helper()
var out struct {
Error string `json:"error"`
Findings []secretFinding `json:"findings"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode 409 body %q: %v", rec.Body, err)
}
if out.Error == "" {
t.Fatalf("409 body carries no error message: %s", rec.Body)
}
return out.Findings
}
// TestShareSecretScan is the gate: a credential-shaped file does not become a
// public URL by accident, and does become one on purpose.
func TestShareSecretScan(t *testing.T) {
srv, p, _, f, h := shareHub(t)
f.put("dev1", "deploy.md", "# Deploy\n\nrun it\n\nAWS_ACCESS_KEY_ID="+planted+"\n")
// 1. blocked, and nothing minted
rec := postShare(t, srv, h, p.ID, map[string]string{"path": "deploy.md"})
if rec.Code != http.StatusConflict {
t.Fatalf("share of a file holding a key: %d %s, want 409", rec.Code, rec.Body)
}
if got := decodeFindings(t, rec); !reflect.DeepEqual(got, []secretFinding{{"aws_access_key_id", 5}}) {
t.Fatalf("findings = %v, want aws_access_key_id on line 5", got)
}
if n := len(srv.Shares.List(p.ID)); n != 0 {
t.Fatalf("409 minted %d shares, want 0", n)
}
// 2. the same request with confirm mints a working link
rec = postShare(t, srv, h, p.ID, map[string]any{"path": "deploy.md", "confirm": true})
if rec.Code != 200 {
t.Fatalf("confirmed share: %d %s", rec.Code, rec.Body)
}
var out struct{ Token string }
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if pub := do(t, h, "GET", "/s/"+out.Token, nil); pub.Code != 200 || !strings.Contains(pub.Body.String(), "Deploy") {
t.Fatalf("confirmed link does not serve: %d %s", pub.Code, pub.Body)
}
// 3. a path that is already public skips the scan — the content is out
// there, so a second Share click still hands back the same URL.
rec = postShare(t, srv, h, p.ID, map[string]string{"path": "deploy.md"})
if rec.Code != 200 {
t.Fatalf("re-share of an already-public file: %d %s, want 200", rec.Code, rec.Body)
}
// 4. a clean file is unaffected
if rec := postShare(t, srv, h, p.ID, map[string]string{"path": "wiki/notes.md"}); rec.Code != 200 {
t.Fatalf("clean file: %d %s, want 200", rec.Code, rec.Body)
}
// 5. the boundary is a decision: a key past the first MiB mints silently.
f.put("dev1", "big.md", strings.Repeat("filler line\n", 100_000)+planted+"\n")
if rec := postShare(t, srv, h, p.ID, map[string]string{"path": "big.md"}); rec.Code != 200 {
t.Fatalf("key past the 1 MiB limit: %d %s, want 200 (documented boundary)", rec.Code, rec.Body)
}
}
// TestShareSecretNeverEchoed is the one rule that cannot bend: the matched
// bytes never leave scanSecrets — not in the body, not in the log.
func TestShareSecretNeverEchoed(t *testing.T) {
srv, p, _, f, h := shareHub(t)
f.put("dev1", "creds.md", "key = "+planted+"\n")
var logs bytes.Buffer
prev := log.Writer()
log.SetOutput(&logs)
t.Cleanup(func() { log.SetOutput(prev) })
rec := postShare(t, srv, h, p.ID, map[string]string{"path": "creds.md"})
if rec.Code != http.StatusConflict {
t.Fatalf("share: %d %s, want 409", rec.Code, rec.Body)
}
if strings.Contains(rec.Body.String(), planted) {
t.Errorf("the 409 body echoed the secret: %s", rec.Body)
}
if strings.Contains(logs.String(), planted) {
t.Errorf("the secret reached the log: %s", logs.String())
}
}
// TestShareSecretScanFailsClosed: a blob the hub cannot read mints nothing.
// A check that skips itself on a storage hiccup is the false confidence this
// gate exists to remove.
func TestShareSecretScanFailsClosed(t *testing.T) {
srv, p, _, f, h := shareHub(t)
f.put("dev1", "gone.md", "harmless")
// Drop the blob but keep the journal op: the file is "synced" and unreadable.
blobs, err := os.ReadDir(filepath.Join(f.dir, "blobs"))
if err != nil {
t.Fatal(err)
}
sum := sha256.Sum256([]byte("harmless"))
want := hex.EncodeToString(sum[:])
for _, b := range blobs {
if b.Name() == want {
if err := os.Remove(filepath.Join(f.dir, "blobs", b.Name())); err != nil {
t.Fatal(err)
}
}
}
rec := postShare(t, srv, h, p.ID, map[string]string{"path": "gone.md"})
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("unreadable blob: %d %s, want 503", rec.Code, rec.Body)
}
if n := len(srv.Shares.List(p.ID)); n != 0 {
t.Fatalf("failed scan minted %d shares, want 0", n)
}
}
// A share token is a promise about ONE file. These three pin the direction
// it goes when a path changes hands — the opposite of the viewer's, which is
// an address and always serves whatever lives there now.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
<script type="module" crossorigin src="/assets/index-DRd_YZgy.js"></script>
<script type="module" crossorigin src="/assets/index-Q_8ZOeQ7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Do25j1to.css">
</head>
<body>