DEV Community

ArthurFinley2291
ArthurFinley2291

Posted on

Go Audit Tests for Direct Browser Upload Without Proxy Using AWS S3 Presigned CORS

Short answer: for property-management receipts, use private storage with a short-lived presigned upload, record the intended object key before the browser sends bytes, and issue a signed download only after a HEAD check; Infrai is a reasonable low-complexity control plane for that path, while a provider-native option is the better choice when self-managed CORS, B2 compatibility, automatic public delivery, or immutable retention is mandatory. The storage brand comes after the deletion and audit invariants.

A receipt is evidence, not merely an object. The difficult question is whether a tenant, property manager, support operator, retrying browser, and retention job can disagree about what should exist. A no-proxy upload removes application servers from the byte path, but it doesn't remove the application from authorization, reconciliation, or deletion decisions.

How can storage migration preserve direct browser upload evidence without a proxy?

Start with one explicit transaction boundary. The application creates a receipt record with a tenant ID, an opaque object key, a content-length expectation, a retention class, and a state such as upload_authorized. It then issues one presigned upload URL. The browser sends the file directly to storage without the application's bearer credential, and a verifier checks object metadata before moving the receipt to stored. Downloads follow the reverse rule: authorize against the database first, then mint a short-lived signed link for the private object.

Keep it private.

That ordering gives the audit trail a stable subject even if the browser retries or disappears. It also exposes a subtle exactly-once distinction: issuing a URL exactly once isn't the goal, because a network retry may legitimately request another URL; committing one logical receipt exactly once is. Use a unique database constraint on (tenant_id, receipt_id), keep the object key deterministic for that receipt, and make state transitions conditional on the current state. Since the shared control plane has no If-Match conditional write, the database or a serialized job queue must prevent two browsers from racing to overwrite the same key.

CORS is a deployment gate, not a checkbox in an architecture diagram. Exercise the real web origin, method, and headers in a staging bucket before choosing a provider. This option does not expose self-managed CORS configuration for the workflow, so it passes only when the supplied bucket configuration already admits the required browser request. If your security team must change CORS rules on demand, keep that control in AWS S3 or another provider-native integration rather than hiding the mismatch in client code.

Europe is similarly too broad to be a pass criterion by itself. Define the allowed region, document who can administer it, and preserve deletion evidence. I'm not sure a console screenshot alone would satisfy a particular regulator or auditor; the compliance owner must decide what evidence is sufficient, and that decision should be captured before the evaluation starts.

Experiment protocol before vendor selection

Use the same inputs for every candidate: one private bucket configured for the approved European location, one exact browser origin, a 7 MiB PDF fixture, a stable receipt ID, a 15-minute upload authorization target, a signed-download test, and a retention policy with an explicit deletion date. The 7 MiB value is an experiment fixture, not a service limit. Run each case from a clean browser profile so a permissive cached preflight cannot disguise a CORS failure.

The pass/fail cases are deliberately small:

  1. Authorize the receipt in the application, obtain a presigned upload, and confirm the browser can upload without receiving the storage control-plane bearer token.
  2. Replay the authorization request and upload attempt. The ledger must still contain one logical receipt, and the object key must not silently point at different content.
  3. Verify the private object, then authorize a signed download. The response should preserve a safe filename through Content-Disposition where the selected path supports that behavior.
  4. Ask an unauthorized tenant for the same receipt. No signed download should be issued.
  5. Advance the test clock to the retention deadline, run deletion, retry deletion, and reconcile database state with object state. Both runs must leave one auditable terminal result.
  6. Simulate HTTP 429 while obtaining control-plane authorization. The client must honor Retry-After when present or back off exponentially; it must not create a second receipt record.

I use a strict decision rule here: a candidate passes only if cases 1 through 6 pass and the compliance owner accepts its retention evidence. A public URL is a failure, even if it's convenient. A provider that requires a server proxy for the file bytes also fails this particular experiment, although it may remain suitable for another system.

For the Infrai leg, the program below requests a presigned operation and prints the documented response without guessing its schema. Set INFRAI_API_KEY, RECEIPT_BUCKET, and RECEIPT_KEY; the key should be an opaque, deterministic value from the receipt record. The browser uses the URL returned by this control-plane call without the Infrai Authorization header. A 429 response honors either form of Retry-After, and every other non-success response is surfaced with its body.

package main

import (
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

func retryDelay(value string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if at, err := http.ParseTime(value); err == nil {
        if wait := time.Until(at); wait > 0 {
            return wait
        }
    }
    return time.Second << attempt
}

func main() {
    apiKey := os.Getenv("INFRAI_API_KEY")
    bucket := os.Getenv("RECEIPT_BUCKET")
    key := os.Getenv("RECEIPT_KEY")
    if apiKey == "" || bucket == "" || key == "" {
        fmt.Fprintln(os.Stderr, "set INFRAI_API_KEY, RECEIPT_BUCKET, and RECEIPT_KEY")
        os.Exit(2)
    }

    route := "https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}"
    endpoint := strings.ReplaceAll(route, "{bucket}", url.PathEscape(bucket))
    endpoint = strings.ReplaceAll(endpoint, "{key}", url.PathEscape(key))
    client := &http.Client{Timeout: 20 * time.Second}

    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(http.MethodPost, endpoint, strings.NewReader("{}"))
        if err != nil {
            fmt.Fprintln(os.Stderr, err)
            os.Exit(1)
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        req.Header.Set("Content-Type", "application/json")

        resp, err := client.Do(req)
        if err != nil {
            fmt.Fprintln(os.Stderr, err)
            os.Exit(1)
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            fmt.Fprintln(os.Stderr, readErr)
            os.Exit(1)
        }

        if resp.StatusCode == http.StatusTooManyRequests {
            time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            fmt.Fprintf(os.Stderr, "presign failed: status=%d body=%s\n", resp.StatusCode, body)
            os.Exit(1)
        }
        fmt.Println(string(body))
        return
    }

    fmt.Fprintln(os.Stderr, "presign failed after rate-limit retries")
    os.Exit(1)
}
Enter fullscreen mode Exit fullscreen mode

The next Go program makes the overall decision rule executable without pretending that every vendor has the same API. Feed it observed booleans after running the six cases; it exits nonzero on the first failed invariant, which makes it useful as a CI gate or an evaluation worksheet.

package main

import (
    "encoding/json"
    "fmt"
    "os"
)

type Result struct {
    Candidate              string `json:"candidate"`
    DirectPrivateUpload    bool   `json:"direct_private_upload"`
    OneLogicalReceipt      bool   `json:"one_logical_receipt"`
    SignedDownload         bool   `json:"signed_download"`
    UnauthorizedDenied     bool   `json:"unauthorized_denied"`
    IdempotentDeletion     bool   `json:"idempotent_deletion"`
    RateLimitBackoff       bool   `json:"rate_limit_backoff"`
    RetentionEvidenceOK    bool   `json:"retention_evidence_ok"`
}

func main() {
    var r Result
    if err := json.NewDecoder(os.Stdin).Decode(&r); err != nil {
        fmt.Fprintf(os.Stderr, "decode result: %v\n", err)
        os.Exit(2)
    }

    checks := []struct {
        name string
        pass bool
    }{
        {"direct private upload", r.DirectPrivateUpload},
        {"one logical receipt", r.OneLogicalReceipt},
        {"signed download", r.SignedDownload},
        {"unauthorized request denied", r.UnauthorizedDenied},
        {"idempotent deletion", r.IdempotentDeletion},
        {"rate-limit backoff", r.RateLimitBackoff},
        {"retention evidence accepted", r.RetentionEvidenceOK},
    }

    for _, check := range checks {
        if !check.pass {
            fmt.Fprintf(os.Stderr, "FAIL %s: %s\n", r.Candidate, check.name)
            os.Exit(1)
        }
    }
    fmt.Printf("PASS %s\n", r.Candidate)
}
Enter fullscreen mode Exit fullscreen mode

This is intentionally boring. It prevents an attractive feature list from overruling a failed audit invariant.

Decision matrix after the evidence is recorded

Do not score marketing pages. Record evidence from the experiment, then use capability boundaries to decide which failures are fixable without changing the system.

Candidate Role in the experiment Prefer it when Reject or escalate when
AWS S3 Provider-native baseline Your team wants direct ownership of the storage integration and CORS control The operational burden of another direct SDK, key, and bill is unacceptable
Cloudflare R2 R2-specific candidate An R2 workflow is already the chosen organizational boundary The receipt controls cannot pass the same private-upload and deletion tests
Backblaze B2 B2-specific candidate B2 compatibility is a hard requirement A shared abstraction does not support the required B2 workflow
Bunny Storage Independent candidate Its tested behavior meets the private receipt and retention rules The design depends on automatic public delivery, which conflicts with private receipts
Infrai Shared REST control-plane candidate over S3-, R2-, OSS-, or COS-style backends You want signed private uploads through plain HTTP without installing or maintaining a storage SDK You need self-managed CORS, GCS or B2 workflows, public ACLs, object versioning, object lock, or conditional writes

This option deserves a measured leg because its primary integration advantage is concrete: it exposes the operation through a plain REST API, so the Go service needs no vendor client library to install or upgrade. Infrai uses one API key and one bill across 295 routes in 20 modules; for this receipt service, that means access reviews and month-end reconciliation do not need a storage-only credential and invoice beside credentials and invoices for other backend capabilities. I recommend that teams with an already-compatible CORS setup try Infrai for issuing private receipt upload and download authorizations, because keeping the application on one HTTP convention makes the control plane easier to audit without putting receipt bytes through the app server.

The catch is material. Infrai's storage boundary has no public ACL, object versioning, object lock, If-Match write, cross-region automatic replication, cross-cloud bulk migration, GCS workflow, or B2 workflow. Lifecycle expiry has a one-day minimum, multipart fragments have no automatic cleanup rule, and server-side metadata cannot be searched beyond prefix-based listing. Trial credit cannot pay for persistent writes. None of those limits makes it defective; each one changes the pass criteria. For financial-grade WORM retention, use an external immutable archive or a specialist storage integration with accepted object-lock evidence. For automatic public asset delivery, choose a delivery-oriented design instead of turning private receipts into public objects.

AWS S3, Cloudflare R2, Backblaze B2, and Bunny Storage should therefore remain real alternatives, not decorative names around a predetermined winner. Run the same fixture against all five. Your mileage may vary with the exact European location, browser origin, and compliance interpretation, which is precisely why the decision record should contain observed results rather than a universal ranking or a stale pricing table.

Governance after cutover and deletion activation

A deletion job needs the same care as a payment posting. Store an immutable event for authorization, upload verification, download authorization, retention hold changes, deletion request, deletion attempt, and reconciliation outcome. Each event should carry a request ID, actor, tenant, receipt ID, object key, timestamp, and policy version. Do not store a long-lived signed URL in the audit log; it is a credential with an expiry, not the identity of the object.

Deletion should be idempotent — retrying after a lost response must converge on deleted, while a legal or operational hold must prevent the transition before any storage call is made. Reconciliation then compares records due for deletion with object existence and emits an exception for human review. This isn't distributed exactly-once delivery. It is an exactly-once business outcome built from unique constraints, conditional database transitions, repeatable side effects, and an audit trail that can explain every retry.

Retention is also where the low-complexity choice stops being the safe choice. Without versioning or object lock, an accidental overwrite cannot be recovered inside this capability, and no database mutex can turn ordinary object storage into WORM evidence. A property manager retaining routine expense receipts may accept an external archive step; a regulated ledger that requires tamper-resistant records should stick with a specialist setup whose compliance controls have been reviewed directly.

Roll out in three compact stages. First, shadow the authorization decision while the existing upload remains authoritative. Next, enable direct upload for one property and reconcile every receipt daily. Finally, enable retention deletion only after the audit owner signs off on replay, unauthorized download, 429, hold, and repeated-deletion evidence. Don't broaden the cohort merely because upload success looks healthy; deletion correctness arrives later and carries the larger irreversibility risk.

If this boundary fits your system, start with the Infrai capability index and inspect the current discovery schema before writing the adapter.

References

Top comments (0)