commit 3a4b6db9b8f59a5a2d5f05a6f6159f2c179c892c Author: Cole Speelman Date: Sun Aug 16 15:05:40 2026 -0400 feat: publish Gamertan Web Foundations preview source Export the reviewed application-neutral package set through the exact public allowlist. Development history and private application evidence remain outside this canonical source root. Developed with material AI assistance under maintainer review. Signed-off-by: Cole Speelman diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..098b64d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: MPL-2.0 + +* text=auto eol=lf +*.go text eol=lf +*.md text eol=lf +*.sql text eol=lf diff --git a/.gitea/workflows/assurance.yml b/.gitea/workflows/assurance.yml new file mode 100644 index 0000000..f119a2d --- /dev/null +++ b/.gitea/workflows/assurance.yml @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: AGPL-3.0-only +name: assurance +on: + schedule: + - cron: '17 9 * * 1' + workflow_dispatch: +permissions: + contents: read +jobs: + assurance: + runs-on: himesan-go1266 + timeout-minutes: 35 + env: + GOTOOLCHAIN: local + GOWORK: off + steps: + - name: Require the repository owner + run: test "$GITHUB_ACTOR" = gamertan + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + fetch-depth: 0 + persist-credentials: false + - name: Verify Go toolchain and modules + run: | + test "$(go env GOVERSION)" = go1.26.6 + go mod download all + go mod verify + - name: Scan known vulnerabilities + run: | + go install golang.org/x/vuln/cmd/govulncheck@v1.7.0 + "$(go env GOPATH)/bin/govulncheck" ./... + - name: Run bounded fuzz campaigns + run: | + go test ./requestmeta -run '^$' -fuzz '^FuzzForwardedChain$' -fuzztime 30s + go test ./analytics -run '^$' -fuzz '^FuzzJSONL$' -fuzztime 30s + - name: Verify reproducible starter build + run: | + mkdir -p "$RUNNER_TEMP/build-a" "$RUNNER_TEMP/build-b" + go build -trimpath -buildvcs=false -o "$RUNNER_TEMP/build-a/basic" ./starters/basic + go build -trimpath -buildvcs=false -o "$RUNNER_TEMP/build-b/basic" ./starters/basic + cmp "$RUNNER_TEMP/build-a/basic" "$RUNNER_TEMP/build-b/basic" + sha256sum "$RUNNER_TEMP/build-a/basic" + - name: Require an unchanged checkout + run: test -z "$(git status --porcelain=v1 --untracked-files=all)" diff --git a/.gitea/workflows/cross-platform.yml b/.gitea/workflows/cross-platform.yml new file mode 100644 index 0000000..42575cb --- /dev/null +++ b/.gitea/workflows/cross-platform.yml @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: AGPL-3.0-only +name: cross-platform-release-gate +on: + workflow_dispatch: +permissions: + contents: read +jobs: + verify: + name: ${{ matrix.os }} / Go ${{ matrix.go }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - os: Linux + runner: himesan-go1266 + go: '1.26.6' + - os: Windows + runner: himesan-windows-go1266 + go: '1.26.6' + steps: + - name: Require the repository owner on Linux + if: matrix.os != 'Windows' + run: test "$GITHUB_ACTOR" = gamertan + - name: Require the repository owner on Windows + if: matrix.os == 'Windows' + shell: pwsh + run: if ($env:GITHUB_ACTOR -ne 'gamertan') { throw 'untrusted actor' } + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + fetch-depth: 0 + persist-credentials: false + - name: Assert the pinned runner toolchain + if: matrix.os != 'Windows' + run: test "$(go env GOVERSION)" = "go${{ matrix.go }}" + - name: Assert the pinned Windows toolchain + if: matrix.os == 'Windows' + shell: pwsh + run: if ((& go env GOVERSION) -ne 'go${{ matrix.go }}') { throw 'unexpected Go toolchain' } + - name: Verify on Linux + if: matrix.os != 'Windows' + run: ./scripts/verify.sh + - name: Verify on Windows + if: matrix.os == 'Windows' + shell: pwsh + run: ./scripts/verify.ps1 + - name: Require an unchanged checkout on Linux + if: matrix.os != 'Windows' + run: test -z "$(git status --porcelain=v1 --untracked-files=all)" + - name: Require an unchanged checkout on Windows + if: matrix.os == 'Windows' + shell: pwsh + run: if (& git status --porcelain=v1 --untracked-files=all) { throw 'dirty checkout' } diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml new file mode 100644 index 0000000..aff69fa --- /dev/null +++ b/.gitea/workflows/verify.yml @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: AGPL-3.0-only +name: verify +on: + push: + branches: [main, 'codex/**'] + workflow_dispatch: +permissions: + contents: read +jobs: + verify: + runs-on: himesan-go1266 + timeout-minutes: 25 + env: + GOTOOLCHAIN: local + GOWORK: off + steps: + - name: Require the repository owner + run: test "$GITHUB_ACTOR" = gamertan + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + fetch-depth: 0 + persist-credentials: false + - name: Verify Go toolchain + run: test "$(go env GOVERSION)" = go1.26.6 + - name: Verify + run: ./scripts/verify.sh + - name: Require an unchanged checkout + run: test -z "$(git status --porcelain=v1 --untracked-files=all)" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a66a84f --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: MPL-2.0 + +/.cache/ +/dist/ +/.env.local +*.test +*.out +*.db +*.db-shm +*.db-wal diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..138b92f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ + + +# Changelog + +## Unreleased + +- Establish independent request metadata, logging, browser security, abuse, + authentication, SQLite, and analytics package boundaries. +- Add a minimal 0BSD `net/http` starter. + +No compatibility promise is made before the first preview tag. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5d31167 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,11 @@ + + +# Contributing + +Canonical issues and contributions live on Gamertan's Gitea. GitHub is a +read-only discovery snapshot. Keep package boundaries application-neutral, +include tests for security-sensitive behavior, use the correct file-level SPDX +identifier, and certify contributions with a DCO `Signed-off-by` line. + +Do not submit credentials, production logs, databases, personal information, or +private application evidence. diff --git a/LICENSES.md b/LICENSES.md new file mode 100644 index 0000000..23eaaab --- /dev/null +++ b/LICENSES.md @@ -0,0 +1,16 @@ + + +# Licensing map + +Every distributed file carries an SPDX identifier. The automated licence check +fails closed on missing or misplaced identifiers. + +| Path | Licence | +| --- | --- | +| Go libraries, adapters, documentation | MPL-2.0 | +| `.gitea/`, `scripts/`, `services/`, future network daemons and operational machinery | AGPL-3.0-only | +| `starters/`, `examples/`, reusable configuration | 0BSD | + +Full texts are in `LICENSES/`. Combining these MPL-covered packages with an +application does not change the licence of the application's own files; changes +to covered files remain subject to the MPL. This summary is not legal advice. diff --git a/LICENSES/0BSD.txt b/LICENSES/0BSD.txt new file mode 100644 index 0000000..540369f --- /dev/null +++ b/LICENSES/0BSD.txt @@ -0,0 +1,12 @@ +Zero-Clause BSD + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/LICENSES/AGPL-3.0-only.txt b/LICENSES/AGPL-3.0-only.txt new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSES/AGPL-3.0-only.txt @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/LICENSES/MPL-2.0.txt b/LICENSES/MPL-2.0.txt new file mode 100644 index 0000000..a612ad9 --- /dev/null +++ b/LICENSES/MPL-2.0.txt @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed144a4 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ + + +# Gamertan Web Foundations + +> Status: unreleased development work toward `v0.1.0-preview.1`. No install +> coordinate is promised until the reviewed public snapshot is tagged. + +Small, composable Go packages for the unglamorous boundaries of a careful web +application: request identity, structured request logs, browser security, +passwords and sessions, permissions, SQLite persistence, and private analytics. + +This is a toolkit, not an application framework. Your application keeps its +router, HTTP policy, HTML, authorization decisions, cache behavior, and +deployment. Each package works with `net/http` and can be adopted independently. + +The first preview targets modest Linux servers, local files, SQLite, and normal +Go binaries. It requires no Redis, message broker, hosted identity provider, +telemetry service, or JavaScript framework. + +## Packages + +- `requestmeta`: trusted-proxy resolution, HTTPS/origin metadata, and request IDs. +- `requestlog`: bounded versioned records, middleware, sinks, and private JSONL. +- `websec`: headers, origin checks, CSRF, redirects, body limits, and rate limits. +- `abuse`: application-classified request abuse with pluggable persistence. +- `auth`, `authhttp`, `authsqlite`: passwords, sessions, permissions, cookies, + and a no-CGO SQLite adapter. +- `analytics`: safe and sensitive aggregate projections over request records. + +The copyable starter under `starters/basic` demonstrates the packages without +turning them into a router or template system. + +## Security boundary + +Client addresses are accepted from forwarding headers only when the immediate +peer and every skipped proxy are explicitly trusted. Sensitive request fields +are off by default. Cryptographic entropy failures fail closed. Logs and account +databases remain private application data and never belong in source releases. + +See [SECURITY.md](SECURITY.md), [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md), +the [application adoption contract](docs/ADOPTION.md), and +[docs/SERVICES_ROADMAP.md](docs/SERVICES_ROADMAP.md). + +## Licensing + +This is a multi-license repository with exact file-level SPDX identifiers: + +- embeddable packages and adapters: MPL-2.0; +- future standalone network services and operational machinery: AGPL-3.0-only; +- starters, examples, and reusable configuration: 0BSD. + +See [LICENSES.md](LICENSES.md). No standalone auth or logging server is included +in this preview. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..0e5a00f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,16 @@ + + +# Security policy + +Report suspected vulnerabilities privately to `security@sandwichhime.com`. +Include the affected package/version, a minimal reproduction, impact, and any +suggested mitigation. Please do not place secrets, personal request logs, live +databases, or exploit details in a public issue. + +The maintainer aims to acknowledge reports within three business days, provide +an initial triage within seven, and keep reporters updated at least every +fourteen days while work remains open. These are best-effort targets, not a +service-level agreement. There is no bug bounty. + +The preview supports only versions explicitly listed in release notes. Security +claims stop at the documented trust boundaries and executable tests. diff --git a/abuse/abuse.go b/abuse/abuse.go new file mode 100644 index 0000000..b6f1bb0 --- /dev/null +++ b/abuse/abuse.go @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Package abuse provides application-classified request enforcement. It does +// not guess which routes or probes are malicious for an application. +package abuse + +import ( + "context" + "errors" + "net/http" + "strconv" + "sync" + "time" + + "gamertan.com/web/requestmeta" +) + +type Severity uint8 + +const ( + Ignore Severity = iota + Strike + ImmediateBlock + PermanentBlock +) + +var ErrCapacity = errors.New("abuse: store capacity exhausted") + +type Signal struct { + Severity Severity + Reason string +} + +type Decision struct { + BlockedUntil time.Time + Permanent bool + Reason string + Strikes int +} + +func (decision Decision) Blocked(now time.Time) bool { + return decision.Permanent || decision.BlockedUntil.After(now) +} + +type Store interface { + Lookup(context.Context, string, time.Time) (Decision, error) + Record(context.Context, string, Signal, time.Time, Policy) (Decision, error) + Pardon(context.Context, string, time.Time) error + Cleanup(context.Context, time.Time, time.Duration, int) error +} + +type Policy struct { + Threshold int + Window time.Duration + BlockDuration time.Duration + Retention time.Duration + MaxClients int +} + +func (policy Policy) Validate() error { + if policy.Threshold < 1 || policy.Threshold > 1000 || policy.Window < time.Second || policy.Window > 24*time.Hour || policy.BlockDuration < time.Second || policy.BlockDuration > 365*24*time.Hour || policy.Retention < policy.Window || policy.Retention > 10*365*24*time.Hour || policy.MaxClients < 1 || policy.MaxClients > 1000000 { + return errors.New("abuse: invalid policy") + } + return nil +} + +type Classifier func(*http.Request) Signal + +type Engine struct { + Store Store + Policy Policy + Classify Classifier + Now func() time.Time + OnError func(error) +} + +func (engine Engine) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if engine.Store == nil || engine.Policy.Validate() != nil { + http.Error(response, "security policy unavailable", http.StatusServiceUnavailable) + return + } + now := time.Now().UTC() + if engine.Now != nil { + now = engine.Now().UTC() + } + metadata, ok := requestmeta.FromContext(request.Context()) + if !ok || !metadata.ClientIP.IsValid() { + http.Error(response, "request identity unavailable", http.StatusServiceUnavailable) + return + } + key := metadata.ClientIP.String() + decision, err := engine.Store.Lookup(request.Context(), key, now) + if err != nil { + engine.fail(response, err) + return + } + if decision.Blocked(now) { + block(response, decision, now) + return + } + signal := Signal{} + if engine.Classify != nil { + signal = engine.Classify(request) + } + if signal.Severity != Ignore { + decision, err = engine.Store.Record(request.Context(), key, signal, now, engine.Policy) + if err != nil { + engine.fail(response, err) + return + } + if decision.Blocked(now) { + block(response, decision, now) + return + } + } + next.ServeHTTP(response, request) + }) +} + +func (engine Engine) fail(response http.ResponseWriter, err error) { + if engine.OnError != nil { + engine.OnError(errors.New("abuse: persistence unavailable")) + } + response.Header().Set("Cache-Control", "no-store") + http.Error(response, "security policy unavailable", http.StatusServiceUnavailable) +} + +func block(response http.ResponseWriter, decision Decision, now time.Time) { + response.Header().Set("Cache-Control", "no-store") + if decision.Permanent { + http.Error(response, "forbidden", http.StatusForbidden) + return + } + seconds := int64(decision.BlockedUntil.Sub(now).Seconds()) + if seconds < 1 { + seconds = 1 + } + response.Header().Set("Retry-After", strconv.FormatInt(seconds, 10)) + http.Error(response, "too many requests", http.StatusTooManyRequests) +} + +// MemoryStore is a bounded single-process reference implementation and test +// adapter. Durable applications should implement Store with private storage. +type MemoryStore struct { + mu sync.Mutex + clients map[string]client +} +type client struct { + first, last time.Time + strikes int + decision Decision +} + +func NewMemoryStore() *MemoryStore { return &MemoryStore{clients: make(map[string]client)} } + +func (store *MemoryStore) Lookup(_ context.Context, key string, now time.Time) (Decision, error) { + if key == "" || len(key) > 128 || now.IsZero() { + return Decision{}, errors.New("abuse: invalid lookup") + } + store.mu.Lock() + defer store.mu.Unlock() + entry, exists := store.clients[key] + if !exists { + return Decision{}, nil + } + if !entry.decision.Permanent && !entry.decision.BlockedUntil.After(now) { + entry.decision.BlockedUntil = time.Time{} + store.clients[key] = entry + } + return entry.decision, nil +} + +func (store *MemoryStore) Record(_ context.Context, key string, signal Signal, now time.Time, policy Policy) (Decision, error) { + if key == "" || len(key) > 128 || signal.Severity < Strike || signal.Severity > PermanentBlock || signal.Reason == "" || len(signal.Reason) > 256 || now.IsZero() || policy.Validate() != nil { + return Decision{}, errors.New("abuse: invalid record") + } + store.mu.Lock() + defer store.mu.Unlock() + entry, exists := store.clients[key] + if !exists && len(store.clients) >= policy.MaxClients && !store.evictOldest() { + return Decision{}, ErrCapacity + } + if entry.first.IsZero() || now.Sub(entry.first) > policy.Window { + entry.first, entry.strikes = now, 0 + } + entry.last = now + entry.strikes++ + entry.decision.Strikes, entry.decision.Reason = entry.strikes, signal.Reason + if signal.Severity == PermanentBlock { + entry.decision.Permanent = true + } + if (signal.Severity == ImmediateBlock || entry.strikes >= policy.Threshold) && !entry.decision.Permanent { + entry.decision.BlockedUntil = now.Add(policy.BlockDuration) + } + store.clients[key] = entry + return entry.decision, nil +} + +func (store *MemoryStore) Pardon(_ context.Context, key string, _ time.Time) error { + if key == "" || len(key) > 128 { + return errors.New("abuse: invalid pardon") + } + store.mu.Lock() + defer store.mu.Unlock() + delete(store.clients, key) + return nil +} + +func (store *MemoryStore) Cleanup(_ context.Context, now time.Time, retention time.Duration, max int) error { + if now.IsZero() || retention <= 0 || max < 1 { + return errors.New("abuse: invalid cleanup") + } + store.mu.Lock() + defer store.mu.Unlock() + for key, entry := range store.clients { + if !entry.decision.Permanent && now.Sub(entry.last) > retention { + delete(store.clients, key) + } + } + for len(store.clients) > max { + if !store.evictOldest() { + return ErrCapacity + } + } + return nil +} + +func (store *MemoryStore) evictOldest() bool { + var selected string + var oldest time.Time + for key, entry := range store.clients { + if entry.decision.Permanent { + continue + } + if selected == "" || entry.last.Before(oldest) { + selected, oldest = key, entry.last + } + } + if selected != "" { + delete(store.clients, selected) + return true + } + return false +} diff --git a/abuse/abuse_test.go b/abuse/abuse_test.go new file mode 100644 index 0000000..94146c0 --- /dev/null +++ b/abuse/abuse_test.go @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MPL-2.0 + +package abuse + +import ( + "errors" + "net/http" + "net/http/httptest" + "net/netip" + "strconv" + "strings" + "testing" + "time" + + "gamertan.com/web/requestmeta" +) + +func TestEngineUsesApplicationClassifier(t *testing.T) { + now := time.Unix(100, 0) + resolver, _ := requestmeta.New(requestmeta.Config{TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}, Random: strings.NewReader(strings.Repeat("a", 64))}) + engine := Engine{Store: NewMemoryStore(), Policy: Policy{Threshold: 2, Window: time.Minute, BlockDuration: time.Hour, Retention: time.Hour, MaxClients: 10}, Now: func() time.Time { return now }, Classify: func(request *http.Request) Signal { + if request.URL.Path == "/application-known-bad" { + return Signal{Severity: Strike, Reason: "application policy"} + } + return Signal{} + }} + called := 0 + handler := resolver.Middleware(engine.Middleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called++ }))) + for index := 0; index < 2; index++ { + request := httptest.NewRequest(http.MethodGet, "http://example.test/application-known-bad", nil) + request.RemoteAddr = "127.0.0.1:1000" + request.Header.Set("X-Forwarded-For", "203.0.113.8") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if index == 0 && response.Code != 200 { + t.Fatalf("first status=%d", response.Code) + } + if index == 1 && response.Code != 429 { + t.Fatalf("second status=%d", response.Code) + } + } + if called != 1 { + t.Fatalf("called=%d", called) + } +} + +func TestImmediateBlockAndPardon(t *testing.T) { + store := NewMemoryStore() + now := time.Unix(100, 0) + policy := Policy{Threshold: 10, Window: time.Minute, BlockDuration: time.Hour, Retention: time.Hour, MaxClients: 10} + decision, err := store.Record(t.Context(), "192.0.2.1", Signal{Severity: ImmediateBlock, Reason: "credential probe"}, now, policy) + if err != nil || decision.Permanent || !decision.Blocked(now) { + t.Fatalf("decision=%+v err=%v", decision, err) + } + if err = store.Pardon(t.Context(), "192.0.2.1", now); err != nil { + t.Fatal(err) + } + decision, _ = store.Lookup(t.Context(), "192.0.2.1", now) + if decision.Blocked(now) { + t.Fatal("pardon did not clear block") + } +} + +func TestPermanentCapacityFailsClosed(t *testing.T) { + store := NewMemoryStore() + now := time.Unix(100, 0) + policy := Policy{Threshold: 10, Window: time.Minute, BlockDuration: time.Hour, Retention: time.Hour, MaxClients: 1} + decision, err := store.Record(t.Context(), "192.0.2.1", Signal{Severity: PermanentBlock, Reason: "operator decision"}, now, policy) + if err != nil || !decision.Permanent { + t.Fatalf("decision=%+v err=%v", decision, err) + } + if _, err = store.Record(t.Context(), "192.0.2.2", Signal{Severity: Strike, Reason: "probe"}, now, policy); !errors.Is(err, ErrCapacity) { + t.Fatalf("err=%v", err) + } + if decision, _ = store.Lookup(t.Context(), "192.0.2.1", now); !decision.Permanent { + t.Fatal("capacity handling evicted permanent decision") + } +} + +func TestLookupDoesNotAllocateUnseenClients(t *testing.T) { + store := NewMemoryStore() + now := time.Unix(100, 0) + for index := 0; index < 100; index++ { + if _, err := store.Lookup(t.Context(), "192.0.2."+strconv.Itoa(index), now); err != nil { + t.Fatal(err) + } + } + if len(store.clients) != 0 { + t.Fatalf("lookups allocated %d entries", len(store.clients)) + } +} diff --git a/analytics/analytics.go b/analytics/analytics.go new file mode 100644 index 0000000..4c6b42d --- /dev/null +++ b/analytics/analytics.go @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Package analytics produces bounded typed projections from requestlog records. +// It does not expose HTTP routes or decide who may view sensitive evidence. +package analytics + +import ( + "bufio" + "encoding/json" + "errors" + "io" + "sort" + "time" + + "gamertan.com/web/requestlog" +) + +type TrafficClass string + +const ( + Public TrafficClass = "public" + Operator TrafficClass = "operator" + Health TrafficClass = "health" + Automated TrafficClass = "automated" +) + +type Classifier func(requestlog.Record) TrafficClass + +type Query struct { + From, Until time.Time + IncludeOperator bool + MaxRecords int + Classify Classifier +} + +type RouteSummary struct { + Route string `json:"route"` + Requests int `json:"requests"` + Errors int `json:"errors"` + Bytes int64 `json:"bytes"` + DurationMicros int64 `json:"duration_micros"` +} +type StatusSummary struct { + Status int `json:"status"` + Requests int `json:"requests"` +} + +type SafeReport struct { + From, Until time.Time + Requests, Errors int + Bytes int64 + P50Micros, P95Micros, P99Micros int64 + Routes []RouteSummary + Statuses []StatusSummary + Truncated bool +} + +type SensitiveRecord struct { + Timestamp time.Time + RequestID, ClientIP, Path, Query, Referer, UserAgent, SessionID string + Status int + DurationMicros int64 +} +type SensitiveReport struct { + Safe SafeReport + Recent []SensitiveRecord +} + +func Safe(records []requestlog.Record, query Query) (SafeReport, error) { + report, _, err := aggregate(records, query, false) + return report, err +} +func Sensitive(records []requestlog.Record, query Query) (SensitiveReport, error) { + safe, recent, err := aggregate(records, query, true) + return SensitiveReport{Safe: safe, Recent: recent}, err +} + +func aggregate(records []requestlog.Record, query Query, includeSensitive bool) (SafeReport, []SensitiveRecord, error) { + if query.MaxRecords == 0 { + query.MaxRecords = 100000 + } + if query.MaxRecords < 1 || query.MaxRecords > 1000000 { + return SafeReport{}, nil, errors.New("analytics: invalid record limit") + } + if !query.From.IsZero() && !query.Until.IsZero() && !query.From.Before(query.Until) { + return SafeReport{}, nil, errors.New("analytics: invalid time range") + } + report := SafeReport{From: query.From.UTC(), Until: query.Until.UTC()} + routeMap := map[string]*RouteSummary{} + statusMap := map[int]int{} + durations := make([]int64, 0, min(len(records), query.MaxRecords)) + recent := make([]SensitiveRecord, 0, 100) + processed := 0 + for _, record := range records { + if err := record.Validate(); err != nil { + return SafeReport{}, nil, errors.New("analytics: invalid request record") + } + if !query.From.IsZero() && record.Timestamp.Before(query.From) || !query.Until.IsZero() && !record.Timestamp.Before(query.Until) { + continue + } + class := Public + if query.Classify != nil { + class = query.Classify(record) + } + if class == Health || class == Operator && !query.IncludeOperator { + continue + } + if processed >= query.MaxRecords { + report.Truncated = true + break + } + processed++ + report.Requests++ + report.Bytes += record.Bytes + if record.Status >= 400 { + report.Errors++ + } + statusMap[record.Status]++ + route := routeMap[record.Route] + if route == nil { + route = &RouteSummary{Route: record.Route} + routeMap[record.Route] = route + } + route.Requests++ + route.Bytes += record.Bytes + route.DurationMicros += record.DurationMicros + if record.Status >= 400 { + route.Errors++ + } + durations = append(durations, record.DurationMicros) + if includeSensitive { + recent = append(recent, SensitiveRecord{Timestamp: record.Timestamp, RequestID: record.RequestID, ClientIP: record.ClientIP, Path: record.Path, Query: record.Query, Referer: record.Referer, UserAgent: record.UserAgent, SessionID: record.SessionID, Status: record.Status, DurationMicros: record.DurationMicros}) + if len(recent) > 100 { + recent = recent[len(recent)-100:] + } + } + } + for _, route := range routeMap { + report.Routes = append(report.Routes, *route) + } + sort.Slice(report.Routes, func(i, j int) bool { + if report.Routes[i].Requests == report.Routes[j].Requests { + return report.Routes[i].Route < report.Routes[j].Route + } + return report.Routes[i].Requests > report.Routes[j].Requests + }) + for status, count := range statusMap { + report.Statuses = append(report.Statuses, StatusSummary{Status: status, Requests: count}) + } + sort.Slice(report.Statuses, func(i, j int) bool { return report.Statuses[i].Status < report.Statuses[j].Status }) + sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] }) + report.P50Micros = percentile(durations, 50) + report.P95Micros = percentile(durations, 95) + report.P99Micros = percentile(durations, 99) + return report, recent, nil +} + +func ReadJSONL(reader io.Reader, maxRecords int) ([]requestlog.Record, error) { + if maxRecords < 1 || maxRecords > 1000000 { + return nil, errors.New("analytics: invalid record limit") + } + scanner := bufio.NewScanner(io.LimitReader(reader, 1<<30)) + scanner.Buffer(make([]byte, 64*1024), 1<<20) + records := make([]requestlog.Record, 0, min(maxRecords, 4096)) + for scanner.Scan() { + if len(records) >= maxRecords { + return nil, errors.New("analytics: record limit exceeded") + } + var record requestlog.Record + if err := json.Unmarshal(scanner.Bytes(), &record); err != nil { + return nil, errors.New("analytics: invalid JSONL record") + } + if err := record.Validate(); err != nil { + return nil, errors.New("analytics: invalid JSONL record") + } + records = append(records, record) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return records, nil +} + +func percentile(values []int64, percent int) int64 { + if len(values) == 0 { + return 0 + } + index := (len(values)*percent+99)/100 - 1 + if index < 0 { + index = 0 + } + if index >= len(values) { + index = len(values) - 1 + } + return values[index] +} diff --git a/analytics/analytics_test.go b/analytics/analytics_test.go new file mode 100644 index 0000000..ebded6c --- /dev/null +++ b/analytics/analytics_test.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MPL-2.0 + +package analytics + +import ( + "strings" + "testing" + "time" + + "gamertan.com/web/requestlog" +) + +func TestSafeProjectionExcludesOperatorAndSensitiveEvidence(t *testing.T) { + now := time.Unix(100, 0).UTC() + records := []requestlog.Record{ + {Version: 1, Timestamp: now, Method: "GET", Route: "home", Status: 200, Bytes: 100, DurationMicros: 10, ClientIP: "private", Query: "secret"}, + {Version: 1, Timestamp: now, Method: "GET", Route: "admin.analytics", Status: 200, Bytes: 200, DurationMicros: 20, ClientIP: "operator"}, + {Version: 1, Timestamp: now, Method: "GET", Route: "home", Status: 500, Bytes: 50, DurationMicros: 30}, + } + classifier := func(record requestlog.Record) TrafficClass { + if strings.HasPrefix(record.Route, "admin.") { + return Operator + } + return Public + } + report, err := Safe(records, Query{Classify: classifier}) + if err != nil { + t.Fatal(err) + } + if report.Requests != 2 || report.Errors != 1 || report.Bytes != 150 || len(report.Routes) != 1 { + t.Fatalf("report=%+v", report) + } + sensitive, err := Sensitive(records, Query{Classify: classifier, IncludeOperator: true}) + if err != nil { + t.Fatal(err) + } + if sensitive.Safe.Requests != 3 || len(sensitive.Recent) != 3 || sensitive.Recent[0].ClientIP != "private" { + t.Fatalf("sensitive=%+v", sensitive) + } +} + +func TestReadJSONLFailsClosed(t *testing.T) { + if _, err := ReadJSONL(strings.NewReader("{not json}\n"), 10); err == nil { + t.Fatal("malformed record accepted") + } + if _, err := ReadJSONL(strings.NewReader(`{"version":99}`+"\n"), 10); err == nil { + t.Fatal("unsupported record accepted") + } +} + +func TestSafeRejectsInvalidTimeRange(t *testing.T) { + now := time.Unix(100, 0) + if _, err := Safe(nil, Query{From: now, Until: now}); err == nil { + t.Fatal("empty time range accepted") + } +} diff --git a/analytics/fuzz_test.go b/analytics/fuzz_test.go new file mode 100644 index 0000000..4814147 --- /dev/null +++ b/analytics/fuzz_test.go @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MPL-2.0 + +package analytics + +import ( + "bytes" + "testing" +) + +func FuzzJSONL(f *testing.F) { + f.Add([]byte(`{"version":1,"timestamp":"2026-01-01T00:00:00Z","method":"GET","route":"home","status":200}` + "\n")) + f.Add([]byte("{bad}\n")) + f.Fuzz(func(t *testing.T, body []byte) { + if len(body) > 1<<20 { + t.Skip() + } + records, err := ReadJSONL(bytes.NewReader(body), 100) + if err == nil && len(records) > 100 { + t.Fatal("record bound exceeded") + } + }) +} diff --git a/analytics/geo.go b/analytics/geo.go new file mode 100644 index 0000000..61a8598 --- /dev/null +++ b/analytics/geo.go @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MPL-2.0 + +package analytics + +import "context" + +// GeoEnricher is deliberately optional. Implementations may use a private local +// database; the base analytics package performs no network lookup. +type GeoEnricher interface { + Lookup(context.Context, string) (GeoEvidence, error) +} +type GeoEvidence struct { + CountryCode, Region, City, ASNOrganization string + ASN uint + Source, Confidence string +} diff --git a/auth/auth.go b/auth/auth.go new file mode 100644 index 0000000..75f2f92 --- /dev/null +++ b/auth/auth.go @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Package auth defines storage-neutral users, credentials, opaque sessions, +// permissions, and audit events. Applications retain authorization policy. +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io" + "regexp" + "sort" + "strings" + "time" +) + +var ( + ErrInvalidCredentials = errors.New("auth: invalid credentials") + ErrInactiveUser = errors.New("auth: account is not active") + ErrSessionNotFound = errors.New("auth: session not found") + ErrUserNotFound = errors.New("auth: user not found") + identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`) +) + +type User struct { + ID, Username, Email, DisplayName, Status string + CreatedAt, UpdatedAt time.Time +} + +type Principal struct { + User User + Roles []string + Permissions map[string]bool +} + +func (principal Principal) Has(permission string) bool { return principal.Permissions[permission] } + +type Session struct { + Digest [32]byte + UserID string + CreatedAt, ExpiresAt, LastSeenAt time.Time +} + +type AuditEvent struct { + ID, ActorUserID, Action, ResourceType, ResourceID, RequestID, Summary string + CreatedAt time.Time +} + +type PolicySeed struct { + Roles map[string]string + Permissions map[string]string + RolePermissions map[string][]string +} + +type Repository interface { + CreateUser(context.Context, User, string) error + CredentialByIdentifier(context.Context, string) (User, string, error) + UpdateLastLogin(context.Context, string, time.Time) error + CreateSession(context.Context, Session) error + PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error) + TouchSession(context.Context, [32]byte, time.Time) error + DeleteSession(context.Context, [32]byte) error + RevokeUserSessions(context.Context, string) error + SeedPolicy(context.Context, PolicySeed) error + GrantRole(context.Context, string, string, time.Time) error + AppendAudit(context.Context, AuditEvent) error +} + +type Service struct { + repository Repository + random io.Reader + now func() time.Time + touchInterval time.Duration +} + +type Options struct { + Random io.Reader + Now func() time.Time + TouchInterval time.Duration +} + +func New(repository Repository, options Options) (*Service, error) { + if repository == nil { + return nil, errors.New("auth: repository is required") + } + if options.Random == nil { + options.Random = rand.Reader + } + if options.Now == nil { + options.Now = time.Now + } + if options.TouchInterval == 0 { + options.TouchInterval = 5 * time.Minute + } + if options.TouchInterval < time.Minute || options.TouchInterval > time.Hour { + return nil, errors.New("auth: invalid session touch interval") + } + return &Service{repository: repository, random: options.Random, now: options.Now, touchInterval: options.TouchInterval}, nil +} + +type CreateUser struct{ Username, Email, DisplayName, Password string } + +func (service *Service) CreateUser(ctx context.Context, input CreateUser) (User, error) { + username := strings.TrimSpace(input.Username) + email := strings.TrimSpace(input.Email) + displayName := strings.TrimSpace(input.DisplayName) + if !identifierPattern.MatchString(username) || email == "" || len(email) > 320 || !strings.Contains(email, "@") || displayName == "" || len(displayName) > 128 { + return User{}, errors.New("auth: invalid user") + } + hash, err := HashPasswordWithRandom(input.Password, service.random) + if err != nil { + return User{}, err + } + id, err := randomToken(service.random, 18) + if err != nil { + return User{}, err + } + now := service.now().UTC() + user := User{ID: id, Username: username, Email: email, DisplayName: displayName, Status: "active", CreatedAt: now, UpdatedAt: now} + if err = service.repository.CreateUser(ctx, user, hash); err != nil { + return User{}, err + } + return user, nil +} + +func (service *Service) Authenticate(ctx context.Context, identifier, password string, lifetime time.Duration) (string, Principal, error) { + if lifetime < 5*time.Minute || lifetime > 30*24*time.Hour { + return "", Principal{}, errors.New("auth: invalid session lifetime") + } + user, hash, err := service.repository.CredentialByIdentifier(ctx, strings.TrimSpace(identifier)) + if errors.Is(err, ErrUserNotFound) { + _ = VerifyPassword(dummyPasswordHash, password) + return "", Principal{}, ErrInvalidCredentials + } + if err != nil { + _ = VerifyPassword(dummyPasswordHash, password) + return "", Principal{}, fmt.Errorf("auth: load credentials: %w", err) + } + if !VerifyPassword(hash, password) { + return "", Principal{}, ErrInvalidCredentials + } + if user.Status != "active" { + return "", Principal{}, ErrInactiveUser + } + token, err := randomToken(service.random, 32) + if err != nil { + return "", Principal{}, err + } + now := service.now().UTC() + digest := sha256.Sum256([]byte(token)) + if err = service.repository.CreateSession(ctx, Session{Digest: digest, UserID: user.ID, CreatedAt: now, ExpiresAt: now.Add(lifetime), LastSeenAt: now}); err != nil { + return "", Principal{}, err + } + _ = service.repository.UpdateLastLogin(ctx, user.ID, now) + principal, _, err := service.repository.PrincipalBySession(ctx, digest, now) + if err != nil { + _ = service.repository.DeleteSession(ctx, digest) + return "", Principal{}, err + } + return token, principal, nil +} + +func (service *Service) Session(ctx context.Context, token string) (Principal, error) { + if len(token) < 32 || len(token) > 128 { + return Principal{}, ErrSessionNotFound + } + digest := sha256.Sum256([]byte(token)) + now := service.now().UTC() + principal, session, err := service.repository.PrincipalBySession(ctx, digest, now) + if errors.Is(err, ErrSessionNotFound) { + return Principal{}, ErrSessionNotFound + } + if err != nil { + return Principal{}, fmt.Errorf("auth: load session: %w", err) + } + if principal.User.Status != "active" { + _ = service.repository.DeleteSession(ctx, digest) + return Principal{}, ErrInactiveUser + } + if now.Sub(session.LastSeenAt) >= service.touchInterval { + _ = service.repository.TouchSession(ctx, digest, now) + } + principal.Roles = sortedUnique(principal.Roles) + if principal.Permissions == nil { + principal.Permissions = map[string]bool{} + } + return principal, nil +} + +func (service *Service) RevokeSession(ctx context.Context, token string) error { + digest := sha256.Sum256([]byte(token)) + return service.repository.DeleteSession(ctx, digest) +} +func (service *Service) RevokeUserSessions(ctx context.Context, userID string) error { + return service.repository.RevokeUserSessions(ctx, userID) +} +func (service *Service) Repository() Repository { return service.repository } + +func randomToken(random io.Reader, bytes int) (string, error) { + value := make([]byte, bytes) + if _, err := io.ReadFull(random, value); err != nil { + return "", fmt.Errorf("auth: secure randomness unavailable: %w", err) + } + return base64.RawURLEncoding.EncodeToString(value), nil +} + +func sortedUnique(values []string) []string { + set := make(map[string]struct{}, len(values)) + for _, value := range values { + if value != "" { + set[value] = struct{}{} + } + } + result := make([]string, 0, len(set)) + for value := range set { + result = append(result, value) + } + sort.Strings(result) + return result +} diff --git a/auth/context.go b/auth/context.go new file mode 100644 index 0000000..71043ec --- /dev/null +++ b/auth/context.go @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MPL-2.0 + +package auth + +import "context" + +type principalKey struct{} + +func WithPrincipal(ctx context.Context, principal Principal) context.Context { + return context.WithValue(ctx, principalKey{}, principal) +} +func PrincipalFromContext(ctx context.Context) (Principal, bool) { + principal, ok := ctx.Value(principalKey{}).(Principal) + return principal, ok +} diff --git a/auth/password.go b/auth/password.go new file mode 100644 index 0000000..9cd9d6f --- /dev/null +++ b/auth/password.go @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MPL-2.0 + +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "io" + "strconv" + "strings" + + "golang.org/x/crypto/argon2" +) + +const ( + passwordMemory = 32 * 1024 + passwordTime = 3 + passwordThreads = 1 + passwordKeyLen = 32 + passwordSaltLen = 16 +) + +func ValidatePassword(password string) error { + if len(password) < 12 { + return errors.New("auth: password must contain at least 12 characters") + } + if len(password) > 1024 { + return errors.New("auth: password is too long") + } + return nil +} + +func HashPassword(password string) (string, error) { + return HashPasswordWithRandom(password, rand.Reader) +} + +func HashPasswordWithRandom(password string, random io.Reader) (string, error) { + if err := ValidatePassword(password); err != nil { + return "", err + } + if random == nil { + return "", errors.New("auth: password entropy source is nil") + } + salt := make([]byte, passwordSaltLen) + if _, err := io.ReadFull(random, salt); err != nil { + return "", fmt.Errorf("auth: generate password salt: %w", err) + } + return encodePassword(password, salt, passwordTime, passwordMemory, passwordThreads, passwordKeyLen), nil +} + +func VerifyPassword(encoded, password string) bool { + parts := strings.Split(encoded, "$") + if len(parts) != 6 || parts[1] != "argon2id" || parts[2] != "v=19" { + return false + } + parameters := map[string]uint64{} + for _, value := range strings.Split(parts[3], ",") { + pair := strings.SplitN(value, "=", 2) + if len(pair) != 2 { + return false + } + parsed, err := strconv.ParseUint(pair[1], 10, 32) + if err != nil { + return false + } + parameters[pair[0]] = parsed + } + memory, iterations, threads := parameters["m"], parameters["t"], parameters["p"] + if len(parameters) != 3 || memory < 8*1024 || memory > 256*1024 || iterations < 1 || iterations > 10 || threads < 1 || threads > 16 { + return false + } + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil || len(salt) < 8 || len(salt) > 64 { + return false + } + want, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil || len(want) < 16 || len(want) > 64 { + return false + } + got := argon2.IDKey([]byte(password), salt, uint32(iterations), uint32(memory), uint8(threads), uint32(len(want))) + return subtle.ConstantTimeCompare(got, want) == 1 +} + +func encodePassword(password string, salt []byte, iterations, memory uint32, threads uint8, keyLen uint32) string { + hash := argon2.IDKey([]byte(password), salt, iterations, memory, threads, keyLen) + return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", memory, iterations, threads, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(hash)) +} + +var dummyPasswordHash = encodePassword("this-account-does-not-exist", []byte("gamertan-web-dummy-salt"), passwordTime, passwordMemory, passwordThreads, passwordKeyLen) diff --git a/auth/password_test.go b/auth/password_test.go new file mode 100644 index 0000000..ff9b7d4 --- /dev/null +++ b/auth/password_test.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MPL-2.0 + +package auth + +import ( + "errors" + "strings" + "testing" +) + +func TestPasswordRoundTripAndBounds(t *testing.T) { + hash, err := HashPasswordWithRandom("correct horse battery staple", strings.NewReader(strings.Repeat("s", 16))) + if err != nil { + t.Fatal(err) + } + if !VerifyPassword(hash, "correct horse battery staple") || VerifyPassword(hash, "wrong password") { + t.Fatal("password verification mismatch") + } + if err = ValidatePassword("short"); err == nil { + t.Fatal("short password accepted") + } +} + +func TestPasswordEntropyFailsClosed(t *testing.T) { + _, err := HashPasswordWithRandom("correct horse battery staple", errorReader{}) + if err == nil { + t.Fatal("entropy failure accepted") + } +} + +type errorReader struct{} + +func (errorReader) Read([]byte) (int, error) { return 0, errors.New("no entropy") } diff --git a/auth/service_test.go b/auth/service_test.go new file mode 100644 index 0000000..c65e2ba --- /dev/null +++ b/auth/service_test.go @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MPL-2.0 + +package auth + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +func TestSessionDistinguishesMissingFromUnavailableStorage(t *testing.T) { + service, err := New(repositoryStub{sessionErr: ErrSessionNotFound}, Options{}) + if err != nil { + t.Fatal(err) + } + if _, err = service.Session(t.Context(), strings.Repeat("x", 43)); !errors.Is(err, ErrSessionNotFound) { + t.Fatalf("missing err=%v", err) + } + + storageErr := errors.New("storage offline") + service, err = New(repositoryStub{sessionErr: storageErr}, Options{}) + if err != nil { + t.Fatal(err) + } + if _, err = service.Session(t.Context(), strings.Repeat("x", 43)); !errors.Is(err, storageErr) || errors.Is(err, ErrSessionNotFound) { + t.Fatalf("storage err=%v", err) + } +} + +type repositoryStub struct{ sessionErr error } + +func (repositoryStub) CreateUser(context.Context, User, string) error { return nil } +func (repositoryStub) CredentialByIdentifier(context.Context, string) (User, string, error) { + return User{}, "", ErrUserNotFound +} +func (repositoryStub) UpdateLastLogin(context.Context, string, time.Time) error { return nil } +func (repositoryStub) CreateSession(context.Context, Session) error { return nil } +func (repository repositoryStub) PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error) { + return Principal{}, Session{}, repository.sessionErr +} +func (repositoryStub) TouchSession(context.Context, [32]byte, time.Time) error { return nil } +func (repositoryStub) DeleteSession(context.Context, [32]byte) error { return nil } +func (repositoryStub) RevokeUserSessions(context.Context, string) error { return nil } +func (repositoryStub) SeedPolicy(context.Context, PolicySeed) error { return nil } +func (repositoryStub) GrantRole(context.Context, string, string, time.Time) error { + return nil +} +func (repositoryStub) AppendAudit(context.Context, AuditEvent) error { return nil } diff --git a/authhttp/authhttp.go b/authhttp/authhttp.go new file mode 100644 index 0000000..56d0797 --- /dev/null +++ b/authhttp/authhttp.go @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Package authhttp connects auth sessions to secure browser cookies and +// request context without owning login pages or application routes. +package authhttp + +import ( + "errors" + "net/http" + "strings" + "time" + + "gamertan.com/web/auth" + "gamertan.com/web/websec" +) + +type CookieConfig struct { + Name string + Lifetime time.Duration + SameSite http.SameSite +} + +func (config CookieConfig) Validate() error { + if !strings.HasPrefix(config.Name, "__Host-") || len(config.Name) > 128 || strings.ContainsAny(config.Name, "\x00\r\n\t ;,") { + return errors.New("authhttp: cookie name must use the __Host- prefix") + } + if config.Lifetime < 5*time.Minute || config.Lifetime > 30*24*time.Hour { + return errors.New("authhttp: invalid cookie lifetime") + } + if config.SameSite == 0 { + config.SameSite = http.SameSiteLaxMode + } + if config.SameSite != http.SameSiteLaxMode && config.SameSite != http.SameSiteStrictMode { + return errors.New("authhttp: SameSite must be Lax or Strict") + } + return nil +} + +func SetSession(response http.ResponseWriter, config CookieConfig, token string, now time.Time) error { + if err := config.Validate(); err != nil { + return err + } + if token == "" || len(token) > 128 { + return errors.New("authhttp: invalid session token") + } + sameSite := config.SameSite + if sameSite == 0 { + sameSite = http.SameSiteLaxMode + } + http.SetCookie(response, &http.Cookie{Name: config.Name, Value: token, Path: "/", Secure: true, HttpOnly: true, SameSite: sameSite, Expires: now.UTC().Add(config.Lifetime), MaxAge: int(config.Lifetime.Seconds())}) + return nil +} + +func ClearSession(response http.ResponseWriter, config CookieConfig) error { + if err := config.Validate(); err != nil { + return err + } + sameSite := config.SameSite + if sameSite == 0 { + sameSite = http.SameSiteLaxMode + } + http.SetCookie(response, &http.Cookie{Name: config.Name, Value: "", Path: "/", Secure: true, HttpOnly: true, SameSite: sameSite, Expires: time.Unix(1, 0), MaxAge: -1}) + return nil +} + +func SessionToken(request *http.Request, config CookieConfig) (string, bool) { + cookie, err := request.Cookie(config.Name) + if err != nil || cookie.Value == "" || len(cookie.Value) > 128 { + return "", false + } + return cookie.Value, true +} + +func Optional(service *auth.Service, config CookieConfig) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if token, ok := SessionToken(request, config); ok { + if principal, err := service.Session(request.Context(), token); err == nil { + request = request.WithContext(auth.WithPrincipal(request.Context(), principal)) + } else if !errors.Is(err, auth.ErrSessionNotFound) && !errors.Is(err, auth.ErrInactiveUser) { + response.Header().Set("Cache-Control", "no-store") + http.Error(response, "authentication unavailable", http.StatusServiceUnavailable) + return + } + } + next.ServeHTTP(response, request) + }) + } +} + +func Require(permission string, next http.Handler) http.Handler { + return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + principal, ok := auth.PrincipalFromContext(request.Context()) + if !ok { + response.Header().Set("Cache-Control", "no-store") + http.Error(response, "authentication required", http.StatusUnauthorized) + return + } + if permission != "" && !principal.Has(permission) { + response.Header().Set("Cache-Control", "no-store") + http.Error(response, "forbidden", http.StatusForbidden) + return + } + next.ServeHTTP(response, request) + }) +} + +func CSRFToken(sessionToken, purpose string) (string, error) { + return websec.CSRFToken([]byte(sessionToken), purpose) +} +func VerifyCSRF(sessionToken, purpose, candidate string) bool { + return websec.VerifyCSRF([]byte(sessionToken), purpose, candidate) +} diff --git a/authhttp/authhttp_test.go b/authhttp/authhttp_test.go new file mode 100644 index 0000000..0f65caf --- /dev/null +++ b/authhttp/authhttp_test.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MPL-2.0 + +package authhttp + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "gamertan.com/web/auth" +) + +func TestSessionCookieContract(t *testing.T) { + config := CookieConfig{Name: "__Host-app_session", Lifetime: time.Hour, SameSite: http.SameSiteStrictMode} + response := httptest.NewRecorder() + if err := SetSession(response, config, strings.Repeat("x", 43), time.Unix(100, 0)); err != nil { + t.Fatal(err) + } + cookies := response.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("cookies=%d", len(cookies)) + } + cookie := cookies[0] + if cookie.Path != "/" || !cookie.Secure || !cookie.HttpOnly || cookie.Domain != "" || cookie.SameSite != http.SameSiteStrictMode { + t.Fatalf("cookie=%+v", cookie) + } +} + +func TestCookieRequiresHostPrefix(t *testing.T) { + if err := (CookieConfig{Name: "session", Lifetime: time.Hour}).Validate(); err == nil { + t.Fatal("weak cookie name accepted") + } +} + +func TestCSRFUsesSessionAndPurpose(t *testing.T) { + token := strings.Repeat("s", 43) + csrf, err := CSRFToken(token, "profile:update") + if err != nil { + t.Fatal(err) + } + if !VerifyCSRF(token, "profile:update", csrf) || VerifyCSRF(token, "profile:delete", csrf) { + t.Fatal("csrf binding failed") + } +} + +func TestOptionalFailsClosedWhenSessionStorageIsUnavailable(t *testing.T) { + service, err := auth.New(authHTTPRepository{err: errors.New("storage offline")}, auth.Options{}) + if err != nil { + t.Fatal(err) + } + config := CookieConfig{Name: "__Host-app_session", Lifetime: time.Hour} + handler := Optional(service, config)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("handler ran while authentication state was unknown") + })) + request := httptest.NewRequest(http.MethodGet, "https://example.test/", nil) + request.AddCookie(&http.Cookie{Name: config.Name, Value: strings.Repeat("x", 43)}) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusServiceUnavailable || response.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("status=%d cache=%q", response.Code, response.Header().Get("Cache-Control")) + } +} + +type authHTTPRepository struct{ err error } + +func (authHTTPRepository) CreateUser(context.Context, auth.User, string) error { return nil } +func (authHTTPRepository) CredentialByIdentifier(context.Context, string) (auth.User, string, error) { + return auth.User{}, "", auth.ErrUserNotFound +} +func (authHTTPRepository) UpdateLastLogin(context.Context, string, time.Time) error { return nil } +func (authHTTPRepository) CreateSession(context.Context, auth.Session) error { return nil } +func (repository authHTTPRepository) PrincipalBySession(context.Context, [32]byte, time.Time) (auth.Principal, auth.Session, error) { + return auth.Principal{}, auth.Session{}, repository.err +} +func (authHTTPRepository) TouchSession(context.Context, [32]byte, time.Time) error { return nil } +func (authHTTPRepository) DeleteSession(context.Context, [32]byte) error { return nil } +func (authHTTPRepository) RevokeUserSessions(context.Context, string) error { return nil } +func (authHTTPRepository) SeedPolicy(context.Context, auth.PolicySeed) error { return nil } +func (authHTTPRepository) GrantRole(context.Context, string, string, time.Time) error { + return nil +} +func (authHTTPRepository) AppendAudit(context.Context, auth.AuditEvent) error { return nil } diff --git a/authsqlite/store.go b/authsqlite/store.go new file mode 100644 index 0000000..67f354d --- /dev/null +++ b/authsqlite/store.go @@ -0,0 +1,317 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Package authsqlite implements auth.Repository using a private no-CGO SQLite +// database and a namespaced schema. +package authsqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "time" + "unicode/utf8" + + "gamertan.com/web/auth" + _ "modernc.org/sqlite" +) + +type Store struct{ db *sql.DB } + +func Open(path string) (*Store, error) { + absolute, err := filepath.Abs(path) + if err != nil { + return nil, err + } + if info, inspectErr := os.Lstat(absolute); inspectErr == nil { + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, errors.New("authsqlite: database must be a regular file, not a symlink") + } + if err = os.Chmod(absolute, 0o600); err != nil { + return nil, fmt.Errorf("authsqlite: secure database mode: %w", err) + } + } else if errors.Is(inspectErr, os.ErrNotExist) { + file, createErr := os.OpenFile(absolute, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) + if createErr != nil { + return nil, fmt.Errorf("authsqlite: create private database: %w", createErr) + } + if closeErr := file.Close(); closeErr != nil { + return nil, fmt.Errorf("authsqlite: create private database: %w", closeErr) + } + } else { + return nil, inspectErr + } + dsn := (&url.URL{Scheme: "file", Path: filepath.ToSlash(absolute), RawQuery: "_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=synchronous(FULL)"}).String() + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(4) + db.SetMaxIdleConns(4) + store := &Store{db: db} + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err = db.PingContext(ctx); err == nil { + err = store.Migrate(ctx) + } + if err != nil { + db.Close() + return nil, fmt.Errorf("authsqlite: open: %w", err) + } + return store, nil +} + +func (store *Store) Close() error { return store.db.Close() } +func (store *Store) Ping(ctx context.Context) error { return store.db.PingContext(ctx) } + +func (store *Store) Migrate(ctx context.Context) error { + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + statements := []string{ + `CREATE TABLE IF NOT EXISTS gamertan_web_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)`, + `CREATE TABLE IF NOT EXISTS gwf_users (id TEXT PRIMARY KEY, username TEXT NOT NULL, username_normalized TEXT NOT NULL UNIQUE, email TEXT NOT NULL, email_normalized TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('active','suspended','disabled')), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER)`, + `CREATE TABLE IF NOT EXISTS gwf_password_credentials (user_id TEXT PRIMARY KEY REFERENCES gwf_users(id) ON DELETE CASCADE, password_hash TEXT NOT NULL, changed_at INTEGER NOT NULL)`, + `CREATE TABLE IF NOT EXISTS gwf_roles (name TEXT PRIMARY KEY, description TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS gwf_permissions (name TEXT PRIMARY KEY, description TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS gwf_role_permissions (role_name TEXT NOT NULL REFERENCES gwf_roles(name) ON DELETE CASCADE, permission_name TEXT NOT NULL REFERENCES gwf_permissions(name) ON DELETE CASCADE, PRIMARY KEY(role_name,permission_name))`, + `CREATE TABLE IF NOT EXISTS gwf_user_roles (user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, role_name TEXT NOT NULL REFERENCES gwf_roles(name) ON DELETE CASCADE, granted_at INTEGER NOT NULL, PRIMARY KEY(user_id,role_name))`, + `CREATE TABLE IF NOT EXISTS gwf_auth_sessions (token_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL)`, + `CREATE INDEX IF NOT EXISTS gwf_auth_sessions_user ON gwf_auth_sessions(user_id)`, + `CREATE INDEX IF NOT EXISTS gwf_auth_sessions_expiry ON gwf_auth_sessions(expires_at)`, + `CREATE TABLE IF NOT EXISTS gwf_audit_events (id TEXT PRIMARY KEY, actor_user_id TEXT REFERENCES gwf_users(id) ON DELETE SET NULL, action TEXT NOT NULL, resource_type TEXT NOT NULL, resource_id TEXT NOT NULL, request_id TEXT, summary TEXT NOT NULL, created_at INTEGER NOT NULL)`, + `CREATE INDEX IF NOT EXISTS gwf_audit_created ON gwf_audit_events(created_at)`, + } + for _, statement := range statements { + if _, err = tx.ExecContext(ctx, statement); err != nil { + return err + } + } + if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(1,?)`, time.Now().UTC().Unix()); err != nil { + return err + } + return tx.Commit() +} + +func (store *Store) CreateUser(ctx context.Context, user auth.User, passwordHash string) error { + if !opaqueID(user.ID) || !text(user.Username, 64, false) || !text(user.Email, 320, false) || !text(user.DisplayName, 128, false) || (user.Status != "active" && user.Status != "suspended" && user.Status != "disabled") || user.CreatedAt.IsZero() || user.UpdatedAt.IsZero() || !text(passwordHash, 1024, false) { + return errors.New("authsqlite: invalid user") + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + _, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, user.CreatedAt.Unix(), user.UpdatedAt.Unix()) + if err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_password_credentials(user_id,password_hash,changed_at) VALUES(?,?,?)`, user.ID, passwordHash, user.CreatedAt.Unix()); err != nil { + return err + } + return tx.Commit() +} + +func (store *Store) CredentialByIdentifier(ctx context.Context, identifier string) (auth.User, string, error) { + if !text(strings.TrimSpace(identifier), 320, false) { + return auth.User{}, "", auth.ErrUserNotFound + } + var user auth.User + var created, updated int64 + var hash string + err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.created_at,u.updated_at,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.username_normalized=? OR u.email_normalized=?`, normalize(identifier), normalize(identifier)).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &created, &updated, &hash) + if errors.Is(err, sql.ErrNoRows) { + return auth.User{}, "", auth.ErrUserNotFound + } + if err != nil { + return auth.User{}, "", err + } + user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC() + return user, hash, nil +} + +func (store *Store) UpdateLastLogin(ctx context.Context, userID string, when time.Time) error { + if !opaqueID(userID) || when.IsZero() { + return errors.New("authsqlite: invalid login update") + } + _, err := store.db.ExecContext(ctx, `UPDATE gwf_users SET last_login_at=?,updated_at=? WHERE id=?`, when.Unix(), when.Unix(), userID) + return err +} + +func (store *Store) CreateSession(ctx context.Context, session auth.Session) error { + if zeroDigest(session.Digest) || !opaqueID(session.UserID) || session.CreatedAt.IsZero() || !session.ExpiresAt.After(session.CreatedAt) || session.LastSeenAt.Before(session.CreatedAt) || session.LastSeenAt.After(session.ExpiresAt) { + return errors.New("authsqlite: invalid session") + } + _, err := store.db.ExecContext(ctx, `INSERT INTO gwf_auth_sessions(token_hash,user_id,created_at,expires_at,last_seen_at) VALUES(?,?,?,?,?)`, session.Digest[:], session.UserID, session.CreatedAt.Unix(), session.ExpiresAt.Unix(), session.LastSeenAt.Unix()) + return err +} + +func (store *Store) PrincipalBySession(ctx context.Context, digest [32]byte, now time.Time) (auth.Principal, auth.Session, error) { + if zeroDigest(digest) || now.IsZero() { + return auth.Principal{}, auth.Session{}, auth.ErrSessionNotFound + } + var principal auth.Principal + var session auth.Session + var created, updated, sessionCreated, expires, lastSeen int64 + err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.created_at,u.updated_at,s.user_id,s.created_at,s.expires_at,s.last_seen_at FROM gwf_auth_sessions s JOIN gwf_users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at>?`, digest[:], now.Unix()).Scan(&principal.User.ID, &principal.User.Username, &principal.User.Email, &principal.User.DisplayName, &principal.User.Status, &created, &updated, &session.UserID, &sessionCreated, &expires, &lastSeen) + if errors.Is(err, sql.ErrNoRows) { + return auth.Principal{}, auth.Session{}, auth.ErrSessionNotFound + } + if err != nil { + return auth.Principal{}, auth.Session{}, err + } + principal.User.CreatedAt, principal.User.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC() + session.Digest = digest + session.CreatedAt = time.Unix(sessionCreated, 0).UTC() + session.ExpiresAt = time.Unix(expires, 0).UTC() + session.LastSeenAt = time.Unix(lastSeen, 0).UTC() + rows, err := store.db.QueryContext(ctx, `SELECT r.name,p.name FROM gwf_user_roles ur JOIN gwf_roles r ON r.name=ur.role_name LEFT JOIN gwf_role_permissions rp ON rp.role_name=r.name LEFT JOIN gwf_permissions p ON p.name=rp.permission_name WHERE ur.user_id=? ORDER BY r.name,p.name`, principal.User.ID) + if err != nil { + return auth.Principal{}, auth.Session{}, err + } + defer rows.Close() + principal.Permissions = map[string]bool{} + roleSet := map[string]struct{}{} + for rows.Next() { + var role string + var permission sql.NullString + if err = rows.Scan(&role, &permission); err != nil { + return auth.Principal{}, auth.Session{}, err + } + roleSet[role] = struct{}{} + if permission.Valid { + principal.Permissions[permission.String] = true + } + } + if err = rows.Err(); err != nil { + return auth.Principal{}, auth.Session{}, err + } + for role := range roleSet { + principal.Roles = append(principal.Roles, role) + } + sort.Strings(principal.Roles) + return principal, session, nil +} + +func (store *Store) TouchSession(ctx context.Context, digest [32]byte, when time.Time) error { + if zeroDigest(digest) || when.IsZero() { + return errors.New("authsqlite: invalid session touch") + } + _, err := store.db.ExecContext(ctx, `UPDATE gwf_auth_sessions SET last_seen_at=? WHERE token_hash=?`, when.Unix(), digest[:]) + return err +} +func (store *Store) DeleteSession(ctx context.Context, digest [32]byte) error { + if zeroDigest(digest) { + return errors.New("authsqlite: invalid session digest") + } + _, err := store.db.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE token_hash=?`, digest[:]) + return err +} +func (store *Store) RevokeUserSessions(ctx context.Context, userID string) error { + if !opaqueID(userID) { + return errors.New("authsqlite: invalid user identifier") + } + _, err := store.db.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, userID) + return err +} + +func (store *Store) SeedPolicy(ctx context.Context, seed auth.PolicySeed) error { + if len(seed.Roles) > 1000 || len(seed.Permissions) > 10000 || len(seed.RolePermissions) > 1000 { + return errors.New("authsqlite: policy is too large") + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + for name, description := range seed.Roles { + if !safeName(name) || !text(description, 512, true) { + return errors.New("authsqlite: invalid role") + } + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_roles(name,description) VALUES(?,?) ON CONFLICT(name) DO UPDATE SET description=excluded.description`, name, description); err != nil { + return err + } + } + for name, description := range seed.Permissions { + if !safeName(name) || !text(description, 512, true) { + return errors.New("authsqlite: invalid permission") + } + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_permissions(name,description) VALUES(?,?) ON CONFLICT(name) DO UPDATE SET description=excluded.description`, name, description); err != nil { + return err + } + } + for role, permissions := range seed.RolePermissions { + if !safeName(role) || len(permissions) > 10000 { + return errors.New("authsqlite: invalid role policy") + } + for _, permission := range permissions { + if !safeName(permission) { + return errors.New("authsqlite: invalid permission policy") + } + if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gwf_role_permissions(role_name,permission_name) VALUES(?,?)`, role, permission); err != nil { + return err + } + } + } + return tx.Commit() +} + +func (store *Store) GrantRole(ctx context.Context, userID, role string, when time.Time) error { + if !opaqueID(userID) || !safeName(role) || when.IsZero() { + return errors.New("authsqlite: invalid role grant") + } + _, err := store.db.ExecContext(ctx, `INSERT OR IGNORE INTO gwf_user_roles(user_id,role_name,granted_at) VALUES(?,?,?)`, userID, role, when.Unix()) + return err +} +func (store *Store) AppendAudit(ctx context.Context, event auth.AuditEvent) error { + if !opaqueID(event.ID) || event.ActorUserID != "" && !opaqueID(event.ActorUserID) || !safeName(event.Action) || !safeName(event.ResourceType) || !text(event.ResourceID, 256, false) || !text(event.RequestID, 128, true) || !text(event.Summary, 1024, true) || event.CreatedAt.IsZero() { + return errors.New("authsqlite: invalid audit event") + } + _, err := store.db.ExecContext(ctx, `INSERT INTO gwf_audit_events(id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at) VALUES(?,NULLIF(?,''),?,?,?,?,?,?)`, event.ID, event.ActorUserID, event.Action, event.ResourceType, event.ResourceID, event.RequestID, event.Summary, event.CreatedAt.Unix()) + return err +} + +func normalize(value string) string { return strings.ToLower(strings.TrimSpace(value)) } +func safeName(value string) bool { + if value == "" || len(value) > 128 { + return false + } + for _, r := range value { + if !(r == '.' || r == '-' || r == '_' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9') { + return false + } + } + return true +} + +func opaqueID(value string) bool { + if len(value) < 8 || len(value) > 128 { + return false + } + for _, character := range value { + if !(character == '-' || character == '_' || character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9') { + return false + } + } + return true +} + +func text(value string, limit int, emptyOK bool) bool { + return (emptyOK || value != "") && len(value) <= limit && utf8.ValidString(value) && !strings.ContainsAny(value, "\x00\r\n") +} + +func zeroDigest(digest [32]byte) bool { + for _, value := range digest { + if value != 0 { + return false + } + } + return true +} diff --git a/authsqlite/store_test.go b/authsqlite/store_test.go new file mode 100644 index 0000000..d206f5e --- /dev/null +++ b/authsqlite/store_test.go @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: MPL-2.0 + +package authsqlite + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "gamertan.com/web/auth" +) + +func TestServiceRoundTripWithApplicationPolicy(t *testing.T) { + store, err := Open(filepath.Join(t.TempDir(), "accounts.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + now := time.Unix(1000, 0).UTC() + service, err := auth.New(store, auth.Options{Random: strings.NewReader(strings.Repeat("r", 512)), Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + if err = store.SeedPolicy(t.Context(), auth.PolicySeed{Roles: map[string]string{"reader": "Read the application"}, Permissions: map[string]string{"catalog.read": "Read catalog"}, RolePermissions: map[string][]string{"reader": {"catalog.read"}}}); err != nil { + t.Fatal(err) + } + user, err := service.CreateUser(t.Context(), auth.CreateUser{Username: "reader.one", Email: "reader@example.test", DisplayName: "Reader", Password: "correct horse battery staple"}) + if err != nil { + t.Fatal(err) + } + if err = store.GrantRole(t.Context(), user.ID, "reader", now); err != nil { + t.Fatal(err) + } + token, principal, err := service.Authenticate(t.Context(), "READER.ONE", "correct horse battery staple", time.Hour) + if err != nil { + t.Fatal(err) + } + if token == "" || !principal.Has("catalog.read") || len(principal.Roles) != 1 { + t.Fatalf("principal=%+v", principal) + } + loaded, err := service.Session(t.Context(), token) + if err != nil || !loaded.Has("catalog.read") { + t.Fatalf("loaded=%+v err=%v", loaded, err) + } + if err = service.RevokeSession(t.Context(), token); err != nil { + t.Fatal(err) + } + if _, err = service.Session(t.Context(), token); err == nil { + t.Fatal("revoked session accepted") + } +} + +func TestSchemaIsNamespacedAndSeedsNothing(t *testing.T) { + path := filepath.Join(t.TempDir(), "accounts.db") + store, err := Open(path) + if err != nil { + t.Fatal(err) + } + defer store.Close() + var count int + if err = store.db.QueryRow(`SELECT COUNT(*) FROM gwf_roles`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("roles=%d", count) + } + var legacy int + err = store.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='users'`).Scan(&legacy) + if err != nil { + t.Fatal(err) + } + if legacy != 0 { + t.Fatal("created unnamespaced users table") + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { + t.Fatalf("mode=%o", info.Mode().Perm()) + } +} + +func TestAdapterRejectsUnboundedPolicyAndInvalidAudit(t *testing.T) { + store, err := Open(filepath.Join(t.TempDir(), "accounts.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if err = store.SeedPolicy(t.Context(), auth.PolicySeed{Roles: map[string]string{"BAD ROLE": "invalid"}}); err == nil { + t.Fatal("invalid role accepted") + } + if err = store.AppendAudit(t.Context(), auth.AuditEvent{ID: "short"}); err == nil { + t.Fatal("invalid audit event accepted") + } +} + +func TestOpenRejectsSymlinkDatabase(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation is privilege-dependent on Windows") + } + directory := t.TempDir() + target := filepath.Join(directory, "target.db") + if err := os.WriteFile(target, nil, 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(directory, "accounts.db") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + if _, err := Open(link); err == nil { + t.Fatal("symlink database accepted") + } +} diff --git a/docs/ADOPTION.md b/docs/ADOPTION.md new file mode 100644 index 0000000..b9bc72c --- /dev/null +++ b/docs/ADOPTION.md @@ -0,0 +1,23 @@ + + +# Application adoption contract + +The foundation is designed and tested as independent software before an +existing application migrates to it. No application's historical schema, +roles, route names, analytics categories, or operator workflow belongs in a +general package merely because it makes one migration easier. + +An application adopts one boundary at a time: + +1. implement a narrow adapter at the application edge; +2. run the old and new decisions against the same reviewed fixtures; +3. explain intentional differences; +4. deploy without deleting the retained implementation; +5. observe a defined production soak; and +6. remove old code only after rollback and data-compatibility evidence passes. + +EQL Helper is intended to be the first demanding adopter, not the design +template. Its private evidence, persistent bans, account data, route policy, +operator exclusions, synchronization, and publishing workflow remain +application-owned. Useful pressure from that migration may improve a general +interface, but it may not smuggle EQL-specific policy into this module. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..9652e21 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,23 @@ + + +# Architecture + +The dependency direction is intentionally one-way: + +```text +net/http application + -> requestmeta + -> requestlog / websec / abuse / authhttp + -> auth and analytics interfaces + -> optional authsqlite and JSONL adapters +``` + +Packages never own application routes, templates, authorization policy, cache +policy, or deployment. Middleware communicates through typed request context. +Storage and reporting surfaces are interfaces so an application can retain its +existing database and user interface while replacing one implementation at a +time. + +The package model is developed from explicit threat and data contracts, not by +moving an existing application's internals into a shared directory. See +[ADOPTION.md](ADOPTION.md). diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md new file mode 100644 index 0000000..251c059 --- /dev/null +++ b/docs/DEPENDENCIES.md @@ -0,0 +1,18 @@ + + +# Dependency boundary + +Most packages use only the Go standard library. Two direct modules are pinned: + +- `golang.org/x/crypto` supplies the reviewed Argon2id implementation used by + `auth` (BSD-3-Clause upstream licence). +- `modernc.org/sqlite` supplies the no-CGO SQLite adapter in `authsqlite` + (BSD-3-Clause upstream licence). + +Applications that do not import `auth` or `authsqlite` do not link those +implementations into their binaries. Optional GeoIP enrichment is an interface +only; the base toolkit performs no lookup and adds no GeoIP dependency. + +`go.sum`, `go mod verify`, checksum-database verification, vulnerability +scanning, and the public snapshot allowlist are release gates. Binary +distributors remain responsible for preserving all applicable upstream notices. diff --git a/docs/PUBLIC_SNAPSHOT.md b/docs/PUBLIC_SNAPSHOT.md new file mode 100644 index 0000000..2a939b8 --- /dev/null +++ b/docs/PUBLIC_SNAPSHOT.md @@ -0,0 +1,11 @@ + + +# Public source snapshots + +Development history remains private. Canonical public source is exported from a +reviewed clean commit through `scripts/public-snapshot.allow`; GitHub receives +the exact same exported tree as a read-only discovery mirror. + +The exporter includes no branches, reflogs, private operational evidence, +credentials, databases, logs, or development-only files. Public Gitea issues +and pull requests are the contribution venue. diff --git a/docs/SERVICES_ROADMAP.md b/docs/SERVICES_ROADMAP.md new file mode 100644 index 0000000..ef46582 --- /dev/null +++ b/docs/SERVICES_ROADMAP.md @@ -0,0 +1,13 @@ + + +# Standalone services: later, deliberately + +The first preview is library-only. An authentication daemon or log ingestion +service would add a network protocol, service authentication, key rotation, +availability, replay, upgrade, and incident-response obligations. Process +isolation is not valuable merely because it draws another box in a diagram. + +If a concrete multi-application need justifies those costs, `authd` and `logd` +will be separate AGPL-3.0-only services. Their protocols will not be promised +until adversarial tests, recovery procedures, and at least two real consumers +exist. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md new file mode 100644 index 0000000..3ad576d --- /dev/null +++ b/docs/THREAT_MODEL.md @@ -0,0 +1,25 @@ + + +# Threat model + +The toolkit treats the public network, forwarding headers, request targets, +cookies, credentials, and stored request records as untrusted. Application code, +the configured trusted-proxy set, server filesystem permissions, and explicitly +selected storage adapters are trusted. + +Controls include explicit proxy trust, bounded parsing, cryptographic request +and session identifiers, digest-only session storage, Argon2id passwords, +constant-time comparisons, same-origin and CSRF primitives, fail-closed storage +errors, and separate safe/sensitive analytics projections. + +The toolkit does not sandbox application handlers, secure an incorrectly +configured reverse proxy, authorize application routes automatically, encrypt a +compromised host, or decide how long an operator may lawfully retain personal +request evidence. + +Local storage adapters assume the parent directory and host account are trusted. +They reject a symlink at the configured final path and apply private file modes, +but they do not defend against a concurrent privileged actor replacing path +ancestors during an open. The synchronous JSONL adapter deliberately favors +durable, bounded evidence over maximum request throughput; the application owns +rotation, retention, disk monitoring, and health escalation. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..52737b3 --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MPL-2.0 + +module gamertan.com/web + +go 1.26 + +require ( + golang.org/x/crypto v0.54.0 + modernc.org/sqlite v1.56.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d88d148 --- /dev/null +++ b/go.sum @@ -0,0 +1,58 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= +modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/requestlog/jsonl.go b/requestlog/jsonl.go new file mode 100644 index 0000000..84c8457 --- /dev/null +++ b/requestlog/jsonl.go @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MPL-2.0 + +package requestlog + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "sync" +) + +// JSONL is a synchronous append-only sink. The caller owns rotation. +type JSONL struct { + mu sync.Mutex + file *os.File + writer *bufio.Writer + err error +} + +func OpenJSONL(path string) (*JSONL, error) { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return nil, errors.New("requestlog: JSONL path must be clean and absolute") + } + if info, err := os.Lstat(path); err == nil && (info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular()) { + return nil, errors.New("requestlog: JSONL destination must be a regular file") + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, err + } + file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return nil, err + } + if err = file.Chmod(0o600); err != nil { + file.Close() + return nil, err + } + return &JSONL{file: file, writer: bufio.NewWriterSize(file, 64*1024)}, nil +} + +func (sink *JSONL) WriteRecord(ctx context.Context, record Record) error { + if err := ctx.Err(); err != nil { + return err + } + if err := record.Validate(); err != nil { + return err + } + body, err := json.Marshal(record) + if err != nil { + return err + } + sink.mu.Lock() + defer sink.mu.Unlock() + if sink.err != nil { + return sink.err + } + if _, err = sink.writer.Write(append(body, '\n')); err == nil { + err = sink.writer.Flush() + } + if err != nil { + sink.err = err + } + return err +} + +func (sink *JSONL) Err() error { sink.mu.Lock(); defer sink.mu.Unlock(); return sink.err } + +func (sink *JSONL) Close() error { + sink.mu.Lock() + defer sink.mu.Unlock() + if sink.file == nil { + return sink.err + } + flushErr := sink.writer.Flush() + closeErr := sink.file.Close() + sink.file = nil + return errors.Join(sink.err, flushErr, closeErr) +} diff --git a/requestlog/requestlog.go b/requestlog/requestlog.go new file mode 100644 index 0000000..ca99899 --- /dev/null +++ b/requestlog/requestlog.go @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Package requestlog records bounded, versioned HTTP request observations. +package requestlog + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + "unicode/utf8" + + "gamertan.com/web/requestmeta" +) + +const RecordVersion = 1 + +// Record is deliberately stable and append-log friendly. Sensitive fields are +// populated only when explicitly enabled by Policy. +type Record struct { + Version int `json:"version"` + Timestamp time.Time `json:"timestamp"` + RequestID string `json:"request_id,omitempty"` + Method string `json:"method"` + Route string `json:"route"` + Status int `json:"status"` + Bytes int64 `json:"bytes"` + DurationMicros int64 `json:"duration_micros"` + ClientIP string `json:"client_ip,omitempty"` + Path string `json:"path,omitempty"` + Query string `json:"query,omitempty"` + Referer string `json:"referer,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + SessionID string `json:"session_id,omitempty"` +} + +// Validate rejects records that cannot have been produced by this package's +// bounded middleware contract. +func (record Record) Validate() error { + if record.Version != RecordVersion || record.Timestamp.IsZero() || !boundedField(record.Method, 16, false) || !boundedField(record.Route, 256, false) || record.Status < 100 || record.Status > 999 || record.Bytes < 0 || record.DurationMicros < 0 { + return errors.New("requestlog: invalid record") + } + fields := []struct { + value string + limit int + }{ + {record.RequestID, 64}, {record.ClientIP, 64}, {record.Path, 2048}, + {record.Query, 4096}, {record.Referer, 2048}, {record.UserAgent, 1024}, + {record.SessionID, 256}, + } + for _, field := range fields { + if !boundedField(field.value, field.limit, true) { + return errors.New("requestlog: invalid record") + } + } + return nil +} + +// Sink receives complete records after a handler returns. +type Sink interface { + WriteRecord(context.Context, Record) error +} + +// SensitiveFields must be opted into field by field. +type SensitiveFields struct { + ClientIP bool + Path bool + Query bool + Referer bool + UserAgent bool + SessionID bool +} + +// Policy controls classification and collection. Route must return a low-cardinality +// route label; nil produces "unclassified" rather than recording a raw path. +type Policy struct { + Route func(*http.Request) string + SessionID func(*http.Request) string + Sensitive SensitiveFields + OnSinkError func(error) + Now func() time.Time +} + +func Middleware(sink Sink, policy Policy) func(http.Handler) http.Handler { + if policy.Now == nil { + policy.Now = time.Now + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + started := policy.Now() + capture := &responseCapture{ResponseWriter: response, status: http.StatusOK} + defer func() { + recovered := recover() + if recovered != nil && !capture.wroteHeader { + capture.status = http.StatusInternalServerError + } + record := makeRecord(request, capture, policy, started, policy.Now()) + if sink != nil { + if err := sink.WriteRecord(context.WithoutCancel(request.Context()), record); err != nil && policy.OnSinkError != nil { + policy.OnSinkError(errors.New("requestlog: sink write failed")) + } + } + if recovered != nil { + panic(recovered) + } + }() + next.ServeHTTP(capture, request) + }) + } +} + +func makeRecord(request *http.Request, capture *responseCapture, policy Policy, started, finished time.Time) Record { + route := "unclassified" + if policy.Route != nil { + route = bounded(policy.Route(request), 256) + if route == "" { + route = "unclassified" + } + } + duration := finished.Sub(started).Microseconds() + if duration < 0 { + duration = 0 + } + record := Record{Version: RecordVersion, Timestamp: finished.UTC(), Method: bounded(request.Method, 16), Route: route, Status: capture.status, Bytes: capture.bytes, DurationMicros: duration} + if metadata, ok := requestmeta.FromContext(request.Context()); ok { + record.RequestID = metadata.RequestID + if policy.Sensitive.ClientIP && metadata.ClientIP.IsValid() { + record.ClientIP = metadata.ClientIP.String() + } + } + if policy.Sensitive.Path { + record.Path = bounded(request.URL.EscapedPath(), 2048) + } + if policy.Sensitive.Query { + record.Query = bounded(request.URL.RawQuery, 4096) + } + if policy.Sensitive.Referer { + record.Referer = bounded(request.Referer(), 2048) + } + if policy.Sensitive.UserAgent { + record.UserAgent = bounded(request.UserAgent(), 1024) + } + if policy.Sensitive.SessionID && policy.SessionID != nil { + record.SessionID = bounded(policy.SessionID(request), 256) + } + return record +} + +func boundedField(value string, limit int, emptyOK bool) bool { + if (!emptyOK && value == "") || len(value) > limit || !utf8.ValidString(value) { + return false + } + for _, character := range value { + if character == 0 || character == '\r' || character == '\n' { + return false + } + } + return true +} + +func bounded(value string, limit int) string { + value = strings.ToValidUTF8(value, "�") + value = strings.Map(func(r rune) rune { + if r == 0 || r == '\r' || r == '\n' { + return -1 + } + return r + }, value) + if len(value) <= limit { + return value + } + value = value[:limit] + for !utf8.ValidString(value) { + value = value[:len(value)-1] + } + return value +} + +type responseCapture struct { + http.ResponseWriter + status int + bytes int64 + wroteHeader bool +} + +func (capture *responseCapture) WriteHeader(status int) { + if capture.wroteHeader { + return + } + capture.wroteHeader = true + capture.status = status + capture.ResponseWriter.WriteHeader(status) +} + +func (capture *responseCapture) Write(body []byte) (int, error) { + if !capture.wroteHeader { + capture.WriteHeader(http.StatusOK) + } + written, err := capture.ResponseWriter.Write(body) + capture.bytes += int64(written) + return written, err +} + +func (capture *responseCapture) Unwrap() http.ResponseWriter { return capture.ResponseWriter } diff --git a/requestlog/requestlog_test.go b/requestlog/requestlog_test.go new file mode 100644 index 0000000..24790c2 --- /dev/null +++ b/requestlog/requestlog_test.go @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: MPL-2.0 + +package requestlog + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/netip" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "gamertan.com/web/requestmeta" +) + +type memorySink struct { + records []Record + err error + ctxErr error +} + +func (sink *memorySink) WriteRecord(ctx context.Context, record Record) error { + sink.records = append(sink.records, record) + sink.ctxErr = ctx.Err() + return sink.err +} + +func TestSafePolicyOmitsSensitiveFields(t *testing.T) { + resolver, _ := requestmeta.New(requestmeta.Config{TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}, Random: strings.NewReader(strings.Repeat("a", 16))}) + sink := &memorySink{} + now := time.Unix(100, 0) + handler := resolver.Middleware(Middleware(sink, Policy{Route: func(*http.Request) string { return "item.show" }, Now: func() time.Time { now = now.Add(time.Millisecond); return now }})(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.WriteHeader(http.StatusCreated) + _, _ = response.Write([]byte("ok")) + }))) + request := httptest.NewRequest(http.MethodGet, "http://example.test/private?id=secret", nil) + request.RemoteAddr = "127.0.0.1:1000" + request.Header.Set("X-Forwarded-For", "203.0.113.9") + request.Header.Set("User-Agent", "private-agent") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if len(sink.records) != 1 { + t.Fatalf("records=%d", len(sink.records)) + } + record := sink.records[0] + if record.Route != "item.show" || record.Status != 201 || record.Bytes != 2 || record.RequestID == "" { + t.Fatalf("record=%+v", record) + } + if record.ClientIP != "" || record.Path != "" || record.Query != "" || record.UserAgent != "" { + t.Fatalf("sensitive leak: %+v", record) + } +} + +func TestSensitivePolicyIsExplicitAndBounded(t *testing.T) { + sink := &memorySink{} + handler := Middleware(sink, Policy{Sensitive: SensitiveFields{Path: true, Query: true, Referer: true, UserAgent: true, SessionID: true}, SessionID: func(*http.Request) string { return "session" }})(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + request := httptest.NewRequest(http.MethodGet, "http://example.test/path?q=value", nil) + request.Header.Set("Referer", "https://ref.example/") + request.Header.Set("User-Agent", "browser") + handler.ServeHTTP(httptest.NewRecorder(), request) + record := sink.records[0] + if record.Path != "/path" || record.Query != "q=value" || record.Referer == "" || record.UserAgent != "browser" || record.SessionID != "session" { + t.Fatalf("record=%+v", record) + } +} + +func TestJSONLRoundTripAndMode(t *testing.T) { + path := filepath.Join(t.TempDir(), "access.jsonl") + sink, err := OpenJSONL(path) + if err != nil { + t.Fatal(err) + } + if err = sink.WriteRecord(context.Background(), Record{Version: 1, Timestamp: time.Unix(100, 0), Method: "GET", Route: "home", Status: 200}); err != nil { + t.Fatal(err) + } + if err = sink.Close(); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { + t.Fatalf("mode=%o", info.Mode().Perm()) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var record Record + if err = json.Unmarshal([]byte(strings.TrimSpace(string(body))), &record); err != nil { + t.Fatal(err) + } + if record.Route != "home" || record.Version != 1 { + t.Fatalf("record=%+v", record) + } +} + +func TestPanicIsRecordedAndRepanicked(t *testing.T) { + sink := &memorySink{} + handler := Middleware(sink, Policy{})(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("expected") })) + defer func() { + if recover() == nil { + t.Fatal("panic was swallowed") + } + if len(sink.records) != 1 || sink.records[0].Status != http.StatusInternalServerError { + t.Fatalf("records=%+v", sink.records) + } + }() + handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "http://example.test/", nil)) +} + +func TestCanceledRequestStillRecordsEvidence(t *testing.T) { + sink := &memorySink{} + handler := Middleware(sink, Policy{})(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil) + ctx, cancel := context.WithCancel(request.Context()) + cancel() + handler.ServeHTTP(httptest.NewRecorder(), request.WithContext(ctx)) + if len(sink.records) != 1 || sink.ctxErr != nil { + t.Fatalf("records=%d ctxErr=%v", len(sink.records), sink.ctxErr) + } +} + +func TestJSONLRejectsInvalidRecord(t *testing.T) { + sink, err := OpenJSONL(filepath.Join(t.TempDir(), "access.jsonl")) + if err != nil { + t.Fatal(err) + } + defer sink.Close() + if err = sink.WriteRecord(context.Background(), Record{Version: 99}); err == nil { + t.Fatal("invalid record accepted") + } +} + +func TestJSONLRejectsSymlinkDestination(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation is privilege-dependent on Windows") + } + directory := t.TempDir() + target := filepath.Join(directory, "target.jsonl") + if err := os.WriteFile(target, nil, 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(directory, "access.jsonl") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + if _, err := OpenJSONL(link); err == nil { + t.Fatal("symlink destination accepted") + } +} + +func TestResponseStatusUsesFirstHeader(t *testing.T) { + sink := &memorySink{} + handler := Middleware(sink, Policy{})(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.WriteHeader(http.StatusNoContent) + response.WriteHeader(http.StatusInternalServerError) + })) + handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "http://example.test/", nil)) + if sink.records[0].Status != http.StatusNoContent { + t.Fatalf("status=%d", sink.records[0].Status) + } +} diff --git a/requestmeta/fuzz_test.go b/requestmeta/fuzz_test.go new file mode 100644 index 0000000..aeb86e3 --- /dev/null +++ b/requestmeta/fuzz_test.go @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MPL-2.0 + +package requestmeta + +import ( + "net/http" + "net/http/httptest" + "net/netip" + "testing" +) + +func FuzzForwardedChain(f *testing.F) { + f.Add("203.0.113.1, 127.0.0.2") + f.Add("not-an-address") + f.Fuzz(func(t *testing.T, forwarded string) { + if len(forwarded) > 8192 { + t.Skip() + } + resolver, _ := New(Config{TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}, Random: zeroReader{}}) + request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil) + request.RemoteAddr = "127.0.0.1:1234" + request.Header.Set("X-Forwarded-For", forwarded) + metadata, err := resolver.Resolve(request) + if err == nil && !metadata.ClientIP.IsValid() { + t.Fatal("successful resolution returned invalid client") + } + }) +} + +type zeroReader struct{} + +func (zeroReader) Read(body []byte) (int, error) { clear(body); return len(body), nil } diff --git a/requestmeta/requestmeta.go b/requestmeta/requestmeta.go new file mode 100644 index 0000000..296b9d6 --- /dev/null +++ b/requestmeta/requestmeta.go @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Package requestmeta resolves bounded request identity and reverse-proxy +// metadata once so security, logging, and application middleware agree. +package requestmeta + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "strings" +) + +const maxForwardedHeader = 4096 + +var ErrInvalidForwarding = errors.New("requestmeta: invalid trusted forwarding metadata") + +type contextKey struct{} + +// Metadata is the normalized request identity shared by downstream packages. +type Metadata struct { + RequestID string + ClientIP netip.Addr + ClientIPSource string + ProxyPeerIP netip.Addr + ForwardedFor string + Scheme string + Host string +} + +// Config chooses the only peers whose forwarding metadata may affect identity. +type Config struct { + TrustedProxies []netip.Prefix + Random io.Reader + RequestIDBytes int +} + +// Resolver is immutable and safe for concurrent use when Random is. +type Resolver struct { + trusted []netip.Prefix + random io.Reader + idBytes int +} + +func New(config Config) (*Resolver, error) { + if config.RequestIDBytes == 0 { + config.RequestIDBytes = 16 + } + if config.RequestIDBytes < 12 || config.RequestIDBytes > 32 { + return nil, errors.New("requestmeta: request ID entropy must be 12 to 32 bytes") + } + if config.Random == nil { + config.Random = rand.Reader + } + trusted := make([]netip.Prefix, len(config.TrustedProxies)) + for i, prefix := range config.TrustedProxies { + if !prefix.IsValid() { + return nil, fmt.Errorf("requestmeta: trusted proxy %d is invalid", i) + } + trusted[i] = prefix.Masked() + } + return &Resolver{trusted: trusted, random: config.Random, idBytes: config.RequestIDBytes}, nil +} + +// Middleware fails closed before application code when entropy or trusted +// forwarding metadata is invalid. +func (resolver *Resolver) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + metadata, err := resolver.Resolve(request) + if err != nil { + response.Header().Set("Cache-Control", "no-store") + status := http.StatusServiceUnavailable + message := "request metadata unavailable" + if errors.Is(err, ErrInvalidForwarding) { + status = http.StatusBadRequest + message = "invalid forwarding metadata" + } + http.Error(response, message, status) + return + } + response.Header().Set("X-Request-ID", metadata.RequestID) + next.ServeHTTP(response, request.WithContext(context.WithValue(request.Context(), contextKey{}, metadata))) + }) +} + +// FromContext returns metadata installed by Middleware. +func FromContext(ctx context.Context) (Metadata, bool) { + metadata, ok := ctx.Value(contextKey{}).(Metadata) + return metadata, ok +} + +func (resolver *Resolver) Resolve(request *http.Request) (Metadata, error) { + requestID, err := resolver.requestID() + if err != nil { + return Metadata{}, fmt.Errorf("requestmeta: generate request ID: %w", err) + } + peer, err := peerAddress(request.RemoteAddr) + if err != nil { + return Metadata{}, err + } + if !validAuthority(request.Host) { + return Metadata{}, ErrInvalidForwarding + } + metadata := Metadata{RequestID: requestID, ClientIP: peer, ClientIPSource: "peer", ProxyPeerIP: peer, Host: request.Host, Scheme: "http"} + if request.TLS != nil { + metadata.Scheme = "https" + } + if !resolver.isTrusted(peer) { + return metadata, nil + } + + forwardedFor := strings.Join(request.Header.Values("X-Forwarded-For"), ",") + forwardedProto, protoOK := uniqueHeader(request.Header, "X-Forwarded-Proto") + forwardedHost, hostOK := uniqueHeader(request.Header, "X-Forwarded-Host") + if !protoOK || !hostOK { + return Metadata{}, ErrInvalidForwarding + } + if len(forwardedFor) > maxForwardedHeader || len(forwardedProto) > 64 || len(forwardedHost) > 1024 { + return Metadata{}, ErrInvalidForwarding + } + if forwardedFor != "" { + chain, err := parseForwardedFor(forwardedFor) + if err != nil { + return Metadata{}, err + } + candidate := peer + for index := len(chain) - 1; index >= 0 && resolver.isTrusted(candidate); index-- { + candidate = chain[index] + } + metadata.ClientIP = candidate + metadata.ClientIPSource = "forwarded" + metadata.ForwardedFor = forwardedFor + } + if forwardedProto != "" { + value, ok := singleForwardedValue(forwardedProto) + if !ok || (value != "http" && value != "https") { + return Metadata{}, ErrInvalidForwarding + } + metadata.Scheme = value + } + if forwardedHost != "" { + value, ok := singleForwardedValue(forwardedHost) + if !ok || !validAuthority(value) { + return Metadata{}, ErrInvalidForwarding + } + metadata.Host = value + } + return metadata, nil +} + +func (resolver *Resolver) requestID() (string, error) { + value := make([]byte, resolver.idBytes) + if _, err := io.ReadFull(resolver.random, value); err != nil { + return "", err + } + return hex.EncodeToString(value), nil +} + +func (resolver *Resolver) isTrusted(address netip.Addr) bool { + for _, prefix := range resolver.trusted { + if prefix.Contains(address) { + return true + } + } + return false +} + +func peerAddress(remote string) (netip.Addr, error) { + host, _, err := net.SplitHostPort(remote) + if err != nil { + return netip.Addr{}, errors.New("requestmeta: remote address must contain host and port") + } + address, err := netip.ParseAddr(host) + if err != nil { + return netip.Addr{}, errors.New("requestmeta: remote address is invalid") + } + return address.Unmap(), nil +} + +func parseForwardedFor(value string) ([]netip.Addr, error) { + parts := strings.Split(value, ",") + if len(parts) == 0 || len(parts) > 32 { + return nil, ErrInvalidForwarding + } + result := make([]netip.Addr, 0, len(parts)) + for _, part := range parts { + address, err := netip.ParseAddr(strings.TrimSpace(part)) + if err != nil { + return nil, ErrInvalidForwarding + } + result = append(result, address.Unmap()) + } + return result, nil +} + +func singleForwardedValue(value string) (string, bool) { + parts := strings.Split(value, ",") + if len(parts) != 1 { + return "", false + } + trimmed := strings.ToLower(strings.TrimSpace(parts[0])) + return trimmed, trimmed != "" +} + +func uniqueHeader(header http.Header, name string) (string, bool) { + values := header.Values(name) + if len(values) > 1 { + return "", false + } + if len(values) == 0 { + return "", true + } + return values[0], true +} + +func validAuthority(value string) bool { + return value != "" && len(value) <= 1024 && !strings.ContainsAny(value, "\\/\x00\r\n\t ,") +} diff --git a/requestmeta/requestmeta_test.go b/requestmeta/requestmeta_test.go new file mode 100644 index 0000000..920b3f1 --- /dev/null +++ b/requestmeta/requestmeta_test.go @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MPL-2.0 + +package requestmeta + +import ( + "errors" + "net/http" + "net/http/httptest" + "net/netip" + "strings" + "testing" +) + +func TestResolverUsesRightmostUntrustedHop(t *testing.T) { + resolver, err := New(Config{TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("10.0.0.0/8")}, Random: strings.NewReader(strings.Repeat("a", 16))}) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil) + request.RemoteAddr = "127.0.0.1:1234" + request.Header.Set("X-Forwarded-For", "198.51.100.20, 203.0.113.9, 10.0.0.4") + request.Header.Set("X-Forwarded-Proto", "https") + metadata, err := resolver.Resolve(request) + if err != nil { + t.Fatal(err) + } + if got := metadata.ClientIP.String(); got != "203.0.113.9" { + t.Fatalf("client=%s", got) + } + if metadata.Scheme != "https" || metadata.ClientIPSource != "forwarded" { + t.Fatalf("metadata=%+v", metadata) + } +} + +func TestResolverIgnoresUntrustedForwarding(t *testing.T) { + resolver, _ := New(Config{TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}, Random: strings.NewReader(strings.Repeat("b", 16))}) + request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil) + request.RemoteAddr = "198.51.100.4:1234" + request.Header.Set("X-Forwarded-For", "203.0.113.9") + metadata, err := resolver.Resolve(request) + if err != nil { + t.Fatal(err) + } + if got := metadata.ClientIP.String(); got != "198.51.100.4" || metadata.ClientIPSource != "peer" { + t.Fatalf("metadata=%+v", metadata) + } +} + +func TestResolverRejectsMalformedTrustedForwarding(t *testing.T) { + resolver, _ := New(Config{TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}, Random: strings.NewReader(strings.Repeat("c", 16))}) + request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil) + request.RemoteAddr = "127.0.0.1:1234" + request.Header.Set("X-Forwarded-For", "not-an-address") + if _, err := resolver.Resolve(request); !errors.Is(err, ErrInvalidForwarding) { + t.Fatalf("err=%v", err) + } +} + +func TestResolverRejectsAmbiguousTrustedForwarding(t *testing.T) { + resolver, _ := New(Config{TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}, Random: strings.NewReader(strings.Repeat("d", 16))}) + request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil) + request.RemoteAddr = "127.0.0.1:1234" + request.Header["X-Forwarded-Proto"] = []string{"https", "http"} + if _, err := resolver.Resolve(request); !errors.Is(err, ErrInvalidForwarding) { + t.Fatalf("err=%v", err) + } +} + +func TestMiddlewareReportsBadForwardingAsBadRequest(t *testing.T) { + resolver, _ := New(Config{TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}, Random: strings.NewReader(strings.Repeat("e", 16))}) + handler := resolver.Middleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("handler ran") })) + request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil) + request.RemoteAddr = "127.0.0.1:1234" + request.Header.Set("X-Forwarded-For", "invalid") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusBadRequest || response.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("status=%d cache=%q", response.Code, response.Header().Get("Cache-Control")) + } +} + +type failingReader struct{} + +func (failingReader) Read([]byte) (int, error) { return 0, errors.New("entropy unavailable") } + +func TestMiddlewareFailsClosedOnEntropyError(t *testing.T) { + resolver, _ := New(Config{Random: failingReader{}}) + called := false + handler := resolver.Middleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })) + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil) + request.RemoteAddr = "192.0.2.1:1234" + handler.ServeHTTP(response, request) + if called || response.Code != http.StatusServiceUnavailable { + t.Fatalf("called=%v status=%d", called, response.Code) + } +} diff --git a/scripts/check-licenses.sh b/scripts/check-licenses.sh new file mode 100755 index 0000000..8995fb9 --- /dev/null +++ b/scripts/check-licenses.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +set -euo pipefail +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "$root" +failed=0 +while IFS= read -r -d '' file; do + case $file in + ./.git/*|./LICENSES/*|./go.sum) continue ;; + ./starters/*|./examples/*) expected=0BSD ;; + ./scripts/*|./.gitea/*|./services/*) expected=AGPL-3.0-only ;; + *) expected=MPL-2.0 ;; + esac + if ! head -n 5 "$file" | grep -Fq "SPDX-License-Identifier: $expected"; then + echo "license mismatch: $file expected $expected" >&2; failed=1 + fi +done < <(find . -type f -print0) +exit "$failed" diff --git a/scripts/export-public.sh b/scripts/export-public.sh new file mode 100755 index 0000000..ea54771 --- /dev/null +++ b/scripts/export-public.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +set -euo pipefail +usage(){ echo "Usage: export-public.sh OUTPUT_DIRECTORY" >&2; exit 2; } +[[ $# -eq 1 ]] || usage +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +output=$1 +[[ $output = /* && $output != / && ! -e $output ]] || usage +cd "$root" +[[ -z $(git status --porcelain=v1 --untracked-files=all) ]] || { echo "private source must be clean" >&2; exit 1; } +mapfile -t files < <(grep -Ev '^[[:space:]]*(#|$)' scripts/public-snapshot.allow) +[[ ${#files[@]} -gt 0 ]] || exit 1 +for file in "${files[@]}"; do + [[ $file != /* && $file != *..* && -f $file && ! -L $file ]] || { echo "invalid allowlisted path: $file" >&2; exit 1; } + git ls-files --error-unmatch -- "$file" >/dev/null +done +mkdir -m 0700 "$output" +git archive --format=tar HEAD -- "${files[@]}" | tar -x -C "$output" +find "$output" -type d -exec chmod 0755 {} + +"$output/scripts/check-licenses.sh" +echo "exported ${#files[@]} reviewed files" diff --git a/scripts/public-snapshot.allow b/scripts/public-snapshot.allow new file mode 100644 index 0000000..da9ad23 --- /dev/null +++ b/scripts/public-snapshot.allow @@ -0,0 +1,56 @@ +# SPDX-License-Identifier: AGPL-3.0-only + +.gitattributes +.gitea/workflows/assurance.yml +.gitea/workflows/cross-platform.yml +.gitea/workflows/verify.yml +.gitignore +CHANGELOG.md +CONTRIBUTING.md +LICENSES.md +LICENSES/0BSD.txt +LICENSES/AGPL-3.0-only.txt +LICENSES/MPL-2.0.txt +README.md +SECURITY.md +abuse/abuse.go +abuse/abuse_test.go +analytics/analytics.go +analytics/analytics_test.go +analytics/fuzz_test.go +analytics/geo.go +auth/auth.go +auth/context.go +auth/password.go +auth/password_test.go +auth/service_test.go +authhttp/authhttp.go +authhttp/authhttp_test.go +authsqlite/store.go +authsqlite/store_test.go +docs/ADOPTION.md +docs/ARCHITECTURE.md +docs/DEPENDENCIES.md +docs/PUBLIC_SNAPSHOT.md +docs/SERVICES_ROADMAP.md +docs/THREAT_MODEL.md +go.mod +go.sum +requestlog/jsonl.go +requestlog/requestlog.go +requestlog/requestlog_test.go +requestmeta/fuzz_test.go +requestmeta/requestmeta.go +requestmeta/requestmeta_test.go +scripts/check-licenses.sh +scripts/export-public.sh +scripts/public-snapshot.allow +scripts/test-public-snapshot.sh +scripts/verify.sh +scripts/verify.ps1 +starters/basic/.env.example +starters/basic/README.md +starters/basic/main.go +websec/ratelimit.go +websec/websec.go +websec/websec_test.go diff --git a/scripts/test-public-snapshot.sh b/scripts/test-public-snapshot.sh new file mode 100755 index 0000000..8cefb11 --- /dev/null +++ b/scripts/test-public-snapshot.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +set -euo pipefail +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "$root" +temporary=$(mktemp -d) +trap 'rm -rf "$temporary"' EXIT +./scripts/export-public.sh "$temporary/export" +(cd "$temporary/export" && find . -type f -printf '%P\n' | sort) >"$temporary/actual" +grep -Ev '^[[:space:]]*(#|$)' scripts/public-snapshot.allow | sort >"$temporary/expected" +diff -u "$temporary/expected" "$temporary/actual" +private_word='PRI''VATE' +token_word='to''ken' +private_pattern="BEGIN (RSA|OPENSSH|EC) ${private_word} KEY|Authorization: ${token_word}|/home/"'cole'"|/mnt/c/"'Users'"|"'eqlwiki'"-deploy|"'crspeelman'"@gmail\\.com" +if rg -n --hidden --glob '!.git/**' "$private_pattern" "$temporary/export"; then + echo "private marker escaped into public snapshot" >&2; exit 1 +fi +test -z "$(git status --porcelain=v1 --untracked-files=all)" diff --git a/scripts/verify.ps1 b/scripts/verify.ps1 new file mode 100644 index 0000000..e7cba53 --- /dev/null +++ b/scripts/verify.ps1 @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: AGPL-3.0-only +$ErrorActionPreference = "Stop" +$root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +Push-Location $root +try { + $failed = $false + Get-ChildItem -Recurse -File | ForEach-Object { + $relative = [IO.Path]::GetRelativePath($root, $_.FullName).Replace('\', '/') + if ($relative.StartsWith('.git/') -or $relative.StartsWith('LICENSES/') -or $relative -eq 'go.sum') { return } + if ($relative.StartsWith('starters/') -or $relative.StartsWith('examples/')) { $expected = '0BSD' } + elseif ($relative.StartsWith('scripts/') -or $relative.StartsWith('.gitea/') -or $relative.StartsWith('services/')) { $expected = 'AGPL-3.0-only' } + else { $expected = 'MPL-2.0' } + $header = (Get-Content -LiteralPath $_.FullName -TotalCount 5) -join "`n" + if (-not $header.Contains("SPDX-License-Identifier: $expected")) { + Write-Error "license mismatch: $relative expected $expected" + $failed = $true + } + } + if ($failed) { throw "license boundary failed" } + + $unformatted = & gofmt -l . + if ($LASTEXITCODE -ne 0 -or $unformatted) { throw "gofmt check failed: $unformatted" } + & go test ./... + if ($LASTEXITCODE -ne 0) { throw "go test failed" } + & go test -race ./... + if ($LASTEXITCODE -ne 0) { throw "race test failed" } + & go vet ./... + if ($LASTEXITCODE -ne 0) { throw "go vet failed" } + $build = Join-Path ([IO.Path]::GetTempPath()) ("gamertan-web-" + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $build | Out-Null + try { + & go build -trimpath -o (Join-Path $build 'basic.exe') ./starters/basic + if ($LASTEXITCODE -ne 0) { throw "starter build failed" } + } finally { + Remove-Item -LiteralPath $build -Recurse -Force + } + & git diff --check + if ($LASTEXITCODE -ne 0) { throw "git diff check failed" } +} finally { + Pop-Location +} diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100755 index 0000000..d14d937 --- /dev/null +++ b/scripts/verify.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +set -euo pipefail +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "$root" +./scripts/check-licenses.sh +test -z "$(gofmt -l .)" +go test ./... +go test -race ./... +go vet ./... +build_dir=$(mktemp -d) +trap 'rm -rf "$build_dir"' EXIT +go build -trimpath -o "$build_dir/basic" ./starters/basic +git diff --check diff --git a/starters/basic/.env.example b/starters/basic/.env.example new file mode 100644 index 0000000..128d966 --- /dev/null +++ b/starters/basic/.env.example @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: 0BSD +# Copy to ignored .env.local only if your own development launcher needs it. +APP_EXAMPLE_VALUE=replace-me diff --git a/starters/basic/README.md b/starters/basic/README.md new file mode 100644 index 0000000..6706c5d --- /dev/null +++ b/starters/basic/README.md @@ -0,0 +1,17 @@ + + +# Basic starter + +This deliberately small server shows package composition without inventing a +router or framework lifecycle. It binds to loopback, applies request metadata +before dependent middleware, emits security headers, optionally appends a +private safe-field JSONL log, and shuts down gracefully. + +Copy it, change it, or do not—we are glad you are here with us. + +```bash +go run ./starters/basic -listen 127.0.0.1:8080 +``` + +Production configuration and secrets belong outside the source tree. This +starter does not load `.env` files automatically. diff --git a/starters/basic/main.go b/starters/basic/main.go new file mode 100644 index 0000000..ba1ef81 --- /dev/null +++ b/starters/basic/main.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: 0BSD + +package main + +import ( + "context" + "flag" + "log" + "net/http" + "net/netip" + "os" + "os/signal" + "time" + + "gamertan.com/web/requestlog" + "gamertan.com/web/requestmeta" + "gamertan.com/web/websec" +) + +func main() { + listen := flag.String("listen", "127.0.0.1:8080", "loopback listen address") + logPath := flag.String("request-log", "", "optional absolute private JSONL path") + flag.Parse() + + resolver, err := requestmeta.New(requestmeta.Config{TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("::1/128")}}) + if err != nil { + log.Fatal(err) + } + + var sink requestlog.Sink + var jsonl *requestlog.JSONL + if *logPath != "" { + jsonl, err = requestlog.OpenJSONL(*logPath) + if err != nil { + log.Fatal(err) + } + defer jsonl.Close() + sink = jsonl + } + + router := http.NewServeMux() + router.HandleFunc("GET /", func(response http.ResponseWriter, _ *http.Request) { + response.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = response.Write([]byte("hello from Gamertan Web Foundations\n")) + }) + router.HandleFunc("GET /healthz", func(response http.ResponseWriter, _ *http.Request) { + response.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = response.Write([]byte("ok\n")) + }) + + var handler http.Handler = router + handler = requestlog.Middleware(sink, requestlog.Policy{Route: func(request *http.Request) string { + if request.URL.Path == "/healthz" { + return "health" + } + return "home" + }, OnSinkError: func(error) { log.Print("request log unavailable") }})(handler) + handler = websec.Headers(func(*http.Request) websec.HeaderPolicy { + return websec.HeaderPolicy{ContentSecurityPolicy: "default-src 'none'; frame-ancestors 'none'; base-uri 'none'", ReferrerPolicy: "no-referrer", FrameOptions: "DENY"} + })(handler) + handler = resolver.Middleware(handler) + + server := &http.Server{Addr: *listen, Handler: handler, ReadHeaderTimeout: 5 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second} + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + go func() { + <-ctx.Done() + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = server.Shutdown(shutdown) + }() + if err = server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatal(err) + } +} diff --git a/websec/ratelimit.go b/websec/ratelimit.go new file mode 100644 index 0000000..651b6fa --- /dev/null +++ b/websec/ratelimit.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MPL-2.0 + +package websec + +import ( + "sync" + "time" +) + +// Limiter is a bounded in-memory token bucket intended for one process. A +// distributed application should provide a different limiter at its boundary. +type Limiter struct { + mu sync.Mutex + rate float64 + burst float64 + maxEntries int + entries map[string]bucket + now func() time.Time +} + +type bucket struct { + tokens float64 + updated time.Time +} + +type LimitConfig struct { + RatePerSecond float64 + Burst int + MaxEntries int + Now func() time.Time +} + +func NewLimiter(config LimitConfig) (*Limiter, error) { + if config.RatePerSecond <= 0 || config.RatePerSecond > 100000 || config.Burst < 1 || config.Burst > 100000 || config.MaxEntries < 1 || config.MaxEntries > 1000000 { + return nil, ErrInvalidLimit + } + if config.Now == nil { + config.Now = time.Now + } + return &Limiter{rate: config.RatePerSecond, burst: float64(config.Burst), maxEntries: config.MaxEntries, entries: make(map[string]bucket), now: config.Now}, nil +} + +var ErrInvalidLimit = limitError("websec: invalid rate-limit configuration") + +type limitError string + +func (err limitError) Error() string { return string(err) } + +func (limiter *Limiter) Allow(key string) bool { + if key == "" || len(key) > 512 { + return false + } + now := limiter.now() + limiter.mu.Lock() + defer limiter.mu.Unlock() + current, exists := limiter.entries[key] + if !exists { + if len(limiter.entries) >= limiter.maxEntries { + limiter.evictOldest() + } + current = bucket{tokens: limiter.burst, updated: now} + } + elapsed := now.Sub(current.updated).Seconds() + if elapsed > 0 { + current.tokens = min(limiter.burst, current.tokens+elapsed*limiter.rate) + current.updated = now + } + if current.tokens < 1 { + limiter.entries[key] = current + return false + } + current.tokens-- + limiter.entries[key] = current + return true +} + +func (limiter *Limiter) evictOldest() { + var oldestKey string + var oldest time.Time + for key, value := range limiter.entries { + if oldestKey == "" || value.updated.Before(oldest) { + oldestKey, oldest = key, value.updated + } + } + delete(limiter.entries, oldestKey) +} diff --git a/websec/websec.go b/websec/websec.go new file mode 100644 index 0000000..f074b7b --- /dev/null +++ b/websec/websec.go @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Package websec supplies small browser and HTTP security primitives without +// taking ownership of application routes or authorization policy. +package websec + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "errors" + "io" + "net/http" + "net/url" + "strings" + + "gamertan.com/web/requestmeta" +) + +type HeaderPolicy struct { + ContentSecurityPolicy string + ReferrerPolicy string + FrameOptions string + PermissionsPolicy string + HSTS string +} + +func Headers(policy func(*http.Request) HeaderPolicy) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + selected := HeaderPolicy{} + if policy != nil { + selected = policy(request) + } + header := response.Header() + header.Set("X-Content-Type-Options", "nosniff") + if selected.ContentSecurityPolicy != "" { + header.Set("Content-Security-Policy", selected.ContentSecurityPolicy) + } + if selected.ReferrerPolicy != "" { + header.Set("Referrer-Policy", selected.ReferrerPolicy) + } + if selected.FrameOptions != "" { + header.Set("X-Frame-Options", selected.FrameOptions) + } + if selected.PermissionsPolicy != "" { + header.Set("Permissions-Policy", selected.PermissionsPolicy) + } + if selected.HSTS != "" { + header.Set("Strict-Transport-Security", selected.HSTS) + } + next.ServeHTTP(response, request) + }) + } +} + +func IsHTTPS(request *http.Request) bool { + if metadata, ok := requestmeta.FromContext(request.Context()); ok { + return metadata.Scheme == "https" + } + return request.TLS != nil +} + +func RequireHTTPS(next http.Handler) http.Handler { + return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if !IsHTTPS(request) { + response.Header().Set("Cache-Control", "no-store") + http.Error(response, "HTTPS required", http.StatusForbidden) + return + } + next.ServeHTTP(response, request) + }) +} + +// SameOrigin accepts browser requests that are demonstrably same-origin. It +// rejects contradictory fetch metadata even when Origin is absent. +func SameOrigin(request *http.Request, allowedOrigin string) bool { + if site := strings.ToLower(strings.TrimSpace(request.Header.Get("Sec-Fetch-Site"))); site != "" && site != "same-origin" && site != "none" { + return false + } + origin := strings.TrimSpace(request.Header.Get("Origin")) + if origin == "" { + return true + } + want, err := url.Parse(allowedOrigin) + if err != nil || want.Scheme == "" || want.Host == "" || want.Path != "" { + return false + } + got, err := url.Parse(origin) + return err == nil && strings.EqualFold(got.Scheme, want.Scheme) && strings.EqualFold(got.Host, want.Host) && got.Path == "" && got.RawQuery == "" && got.Fragment == "" +} + +// CSRFToken binds a purpose to opaque session secret material. +func CSRFToken(sessionSecret []byte, purpose string) (string, error) { + if len(sessionSecret) < 16 || purpose == "" || len(purpose) > 128 || strings.ContainsAny(purpose, "\x00\r\n") { + return "", errors.New("websec: invalid CSRF input") + } + mac := hmac.New(sha256.New, sessionSecret) + _, _ = io.WriteString(mac, purpose) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil +} + +func VerifyCSRF(sessionSecret []byte, purpose, candidate string) bool { + want, err := CSRFToken(sessionSecret, purpose) + if err != nil || len(candidate) != len(want) { + return false + } + return subtle.ConstantTimeCompare([]byte(candidate), []byte(want)) == 1 +} + +func SafeLocalRedirect(value, fallback string) string { + if !safePath(fallback) { + fallback = "/" + } + parsed, err := url.Parse(value) + if err != nil || parsed.IsAbs() || parsed.Host != "" || !safePath(parsed.Path) || strings.HasPrefix(value, "//") || parsed.User != nil { + return fallback + } + return parsed.RequestURI() +} + +func safePath(value string) bool { + return strings.HasPrefix(value, "/") && !strings.HasPrefix(value, "//") && !strings.ContainsAny(value, "\x00\r\n\\") +} + +func LimitBody(response http.ResponseWriter, request *http.Request, bytes int64) error { + if bytes <= 0 { + return errors.New("websec: body limit must be positive") + } + request.Body = http.MaxBytesReader(response, request.Body, bytes) + return nil +} diff --git a/websec/websec_test.go b/websec/websec_test.go new file mode 100644 index 0000000..5aa8478 --- /dev/null +++ b/websec/websec_test.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MPL-2.0 + +package websec + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestSameOriginRejectsCrossSiteAndContradiction(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "https://example.test/change", nil) + request.Header.Set("Origin", "https://example.test") + request.Header.Set("Sec-Fetch-Site", "same-origin") + if !SameOrigin(request, "https://example.test") { + t.Fatal("same origin rejected") + } + request.Header.Set("Sec-Fetch-Site", "cross-site") + if SameOrigin(request, "https://example.test") { + t.Fatal("cross-site accepted") + } + request.Header.Set("Sec-Fetch-Site", "same-origin") + request.Header.Set("Origin", "https://attacker.test") + if SameOrigin(request, "https://example.test") { + t.Fatal("foreign origin accepted") + } +} + +func TestCSRFIsPurposeBound(t *testing.T) { + secret := []byte("0123456789abcdef0123456789abcdef") + token, err := CSRFToken(secret, "account:update") + if err != nil { + t.Fatal(err) + } + if !VerifyCSRF(secret, "account:update", token) { + t.Fatal("valid token rejected") + } + if VerifyCSRF(secret, "account:delete", token) { + t.Fatal("cross-purpose token accepted") + } +} + +func TestSafeLocalRedirect(t *testing.T) { + for _, unsafe := range []string{"https://attacker.test/", "//attacker.test/", "/\\attacker", "javascript:alert(1)"} { + if got := SafeLocalRedirect(unsafe, "/home"); got != "/home" { + t.Fatalf("%q => %q", unsafe, got) + } + } + if got := SafeLocalRedirect("/items?page=2", "/"); got != "/items?page=2" { + t.Fatalf("got=%q", got) + } +} + +func TestLimiterRefillsAndStaysBounded(t *testing.T) { + now := time.Unix(100, 0) + limiter, err := NewLimiter(LimitConfig{RatePerSecond: 1, Burst: 2, MaxEntries: 2, Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + if !limiter.Allow("a") || !limiter.Allow("a") || limiter.Allow("a") { + t.Fatal("unexpected initial budget") + } + now = now.Add(time.Second) + if !limiter.Allow("a") { + t.Fatal("token did not refill") + } + _ = limiter.Allow("b") + _ = limiter.Allow("c") + if len(limiter.entries) != 2 { + t.Fatalf("entries=%d", len(limiter.entries)) + } +}